@markjaquith/agency 1.5.0 → 1.5.2
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/cli.ts +14 -4
- package/package.json +1 -1
- package/src/commands/emit.test.ts +43 -0
- package/src/services/AgencyMetadataService.ts +50 -5
- package/src/services/FileSystemService.ts +38 -1
- package/src/types.ts +5 -1
package/cli.ts
CHANGED
|
@@ -124,14 +124,24 @@ const commands: Record<string, Command> = {
|
|
|
124
124
|
pr: {
|
|
125
125
|
name: "pr",
|
|
126
126
|
description: "Run gh pr with the emitted branch name",
|
|
127
|
-
run: async (
|
|
127
|
+
run: async (
|
|
128
|
+
_args: string[],
|
|
129
|
+
options: Record<string, any>,
|
|
130
|
+
rawArgs?: string[],
|
|
131
|
+
) => {
|
|
128
132
|
if (options.help) {
|
|
129
133
|
console.log(prHelp)
|
|
130
134
|
return
|
|
131
135
|
}
|
|
136
|
+
// Pass raw args (after filtering agency flags) directly to gh pr
|
|
137
|
+
// This allows flags like --web to pass through without needing --
|
|
138
|
+
const agencyFlags = ["--help", "-h", "--silent", "-s", "--verbose", "-v"]
|
|
139
|
+
const filteredArgs = (rawArgs ?? []).filter(
|
|
140
|
+
(arg) => !agencyFlags.includes(arg),
|
|
141
|
+
)
|
|
132
142
|
await runCommand(
|
|
133
143
|
pr({
|
|
134
|
-
args,
|
|
144
|
+
args: filteredArgs,
|
|
135
145
|
silent: options.silent,
|
|
136
146
|
verbose: options.verbose,
|
|
137
147
|
}),
|
|
@@ -621,8 +631,8 @@ try {
|
|
|
621
631
|
allowPositionals: true,
|
|
622
632
|
})
|
|
623
633
|
|
|
624
|
-
// Run the command
|
|
625
|
-
await command.run(cmdPositionals, cmdValues)
|
|
634
|
+
// Run the command, passing raw args for commands that need them (like pr)
|
|
635
|
+
await command.run(cmdPositionals, cmdValues, commandArgs)
|
|
626
636
|
} catch (error) {
|
|
627
637
|
if (error instanceof Error) {
|
|
628
638
|
console.error(`ⓘ ${error.message}`)
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
2
|
import { join } from "path"
|
|
3
|
+
import { symlink } from "fs/promises"
|
|
3
4
|
import { emit } from "../commands/emit"
|
|
4
5
|
import { task } from "../commands/task"
|
|
5
6
|
import {
|
|
@@ -323,5 +324,47 @@ describe("emit command", () => {
|
|
|
323
324
|
// Should have GIT_CONFIG_GLOBAL env set
|
|
324
325
|
expect(lastCall!.env?.GIT_CONFIG_GLOBAL).toBe("")
|
|
325
326
|
})
|
|
327
|
+
|
|
328
|
+
test("includes symlink targets in files to filter", async () => {
|
|
329
|
+
// Set up fresh branch
|
|
330
|
+
await checkoutBranch(tempDir, "main")
|
|
331
|
+
await createBranch(tempDir, "agency/symlink-test")
|
|
332
|
+
|
|
333
|
+
// Create AGENTS.md as the real file
|
|
334
|
+
await Bun.write(join(tempDir, "AGENTS.md"), "# Real file\n")
|
|
335
|
+
|
|
336
|
+
// Create CLAUDE.md as a symlink to AGENTS.md
|
|
337
|
+
await symlink("AGENTS.md", join(tempDir, "CLAUDE.md"))
|
|
338
|
+
|
|
339
|
+
// Create agency.json
|
|
340
|
+
await Bun.write(
|
|
341
|
+
join(tempDir, "agency.json"),
|
|
342
|
+
JSON.stringify({
|
|
343
|
+
version: 1,
|
|
344
|
+
injectedFiles: [],
|
|
345
|
+
template: "test",
|
|
346
|
+
createdAt: new Date().toISOString(),
|
|
347
|
+
}),
|
|
348
|
+
)
|
|
349
|
+
await addAndCommit(
|
|
350
|
+
tempDir,
|
|
351
|
+
["AGENTS.md", "CLAUDE.md", "agency.json"],
|
|
352
|
+
"Add files with symlink",
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
// Run emit with mock filter-repo
|
|
356
|
+
await runTestEffectWithMockFilterRepo(emit({ silent: true }))
|
|
357
|
+
|
|
358
|
+
// Verify filter-repo was called with both CLAUDE.md AND AGENTS.md
|
|
359
|
+
const lastCall = getLastCapturedFilterRepoCall()
|
|
360
|
+
expect(lastCall).toBeDefined()
|
|
361
|
+
|
|
362
|
+
// Should include CLAUDE.md (the symlink)
|
|
363
|
+
expect(lastCall!.args).toContain("CLAUDE.md")
|
|
364
|
+
|
|
365
|
+
// Should also include AGENTS.md (the symlink target)
|
|
366
|
+
// This is the key assertion - the symlink target should be filtered too
|
|
367
|
+
expect(lastCall!.args).toContain("AGENTS.md")
|
|
368
|
+
})
|
|
326
369
|
})
|
|
327
370
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join } from "node:path"
|
|
1
|
+
import { join, dirname, resolve, relative } from "node:path"
|
|
2
2
|
import { Context, Data, Effect, Layer } from "effect"
|
|
3
3
|
import { Schema } from "@effect/schema"
|
|
4
4
|
import { AgencyMetadata } from "../schemas"
|
|
@@ -104,6 +104,49 @@ const parseAgencyMetadata = (content: string) =>
|
|
|
104
104
|
return metadata
|
|
105
105
|
}).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Resolve symlink targets for a list of files.
|
|
109
|
+
* For each file, if it's a symlink, also adds the target path to the result.
|
|
110
|
+
* This ensures that when filtering files from git history, both the symlink
|
|
111
|
+
* and its target are filtered (important when CLAUDE.md → AGENTS.md, for example).
|
|
112
|
+
*/
|
|
113
|
+
const resolveSymlinkTargets = (
|
|
114
|
+
fs: FileSystemService,
|
|
115
|
+
gitRoot: string,
|
|
116
|
+
files: string[],
|
|
117
|
+
) =>
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
const result = new Set<string>(files)
|
|
120
|
+
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
const fullPath = join(gitRoot, file)
|
|
123
|
+
const target = yield* fs.readSymlinkTarget(fullPath)
|
|
124
|
+
|
|
125
|
+
if (target) {
|
|
126
|
+
// The target path could be relative or absolute
|
|
127
|
+
// If relative, it's relative to the symlink's location
|
|
128
|
+
// We need to normalize it to be relative to gitRoot
|
|
129
|
+
let targetPath: string
|
|
130
|
+
if (target.startsWith("/")) {
|
|
131
|
+
// Absolute path - make it relative to gitRoot
|
|
132
|
+
targetPath = relative(gitRoot, target)
|
|
133
|
+
} else {
|
|
134
|
+
// Relative path - resolve from the symlink's directory
|
|
135
|
+
const symlinkDir = dirname(fullPath)
|
|
136
|
+
const absoluteTarget = resolve(symlinkDir, target)
|
|
137
|
+
targetPath = relative(gitRoot, absoluteTarget)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Only add if it's within the gitRoot (not escaping the repo)
|
|
141
|
+
if (!targetPath.startsWith("..")) {
|
|
142
|
+
result.add(targetPath)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return Array.from(result)
|
|
148
|
+
})
|
|
149
|
+
|
|
107
150
|
/**
|
|
108
151
|
* Implementation of AgencyMetadataService
|
|
109
152
|
*/
|
|
@@ -164,7 +207,8 @@ export const AgencyMetadataServiceLive = Layer.succeed(
|
|
|
164
207
|
const baseFiles = ["TASK.md", "AGENCY.md", "CLAUDE.md", "agency.json"]
|
|
165
208
|
|
|
166
209
|
if (!exists) {
|
|
167
|
-
|
|
210
|
+
// Resolve symlinks even for base files
|
|
211
|
+
return yield* resolveSymlinkTargets(fs, gitRoot, baseFiles)
|
|
168
212
|
}
|
|
169
213
|
|
|
170
214
|
const content = yield* fs
|
|
@@ -172,13 +216,13 @@ export const AgencyMetadataServiceLive = Layer.succeed(
|
|
|
172
216
|
.pipe(Effect.catchAll(() => Effect.succeed("")))
|
|
173
217
|
|
|
174
218
|
if (!content) {
|
|
175
|
-
return baseFiles
|
|
219
|
+
return yield* resolveSymlinkTargets(fs, gitRoot, baseFiles)
|
|
176
220
|
}
|
|
177
221
|
|
|
178
222
|
const metadata = yield* parseAgencyMetadata(content)
|
|
179
223
|
|
|
180
224
|
if (!metadata) {
|
|
181
|
-
return baseFiles
|
|
225
|
+
return yield* resolveSymlinkTargets(fs, gitRoot, baseFiles)
|
|
182
226
|
}
|
|
183
227
|
|
|
184
228
|
// Expand any glob patterns in injectedFiles to actual file paths
|
|
@@ -188,7 +232,8 @@ export const AgencyMetadataServiceLive = Layer.succeed(
|
|
|
188
232
|
catch: () => new Error("Failed to expand glob patterns"),
|
|
189
233
|
})
|
|
190
234
|
|
|
191
|
-
|
|
235
|
+
// Resolve symlinks and add their targets
|
|
236
|
+
return yield* resolveSymlinkTargets(fs, gitRoot, expandedFiles)
|
|
192
237
|
}).pipe(
|
|
193
238
|
Effect.catchAll(() =>
|
|
194
239
|
Effect.succeed(["TASK.md", "AGENCY.md", "CLAUDE.md", "agency.json"]),
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { Effect, Data, pipe } from "effect"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
copyFile as fsCopyFile,
|
|
5
|
+
unlink,
|
|
6
|
+
lstat,
|
|
7
|
+
readlink,
|
|
8
|
+
} from "node:fs/promises"
|
|
3
9
|
import { spawnProcess } from "../utils/process"
|
|
4
10
|
|
|
5
11
|
// Error types for FileSystem operations
|
|
@@ -219,6 +225,37 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
|
|
|
219
225
|
cause: error,
|
|
220
226
|
}),
|
|
221
227
|
}),
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if a path is a symbolic link.
|
|
231
|
+
* Returns false if the file doesn't exist or if it's not a symlink.
|
|
232
|
+
*/
|
|
233
|
+
isSymlink: (path: string) =>
|
|
234
|
+
Effect.tryPromise({
|
|
235
|
+
try: async () => {
|
|
236
|
+
const stats = await lstat(path)
|
|
237
|
+
return stats.isSymbolicLink()
|
|
238
|
+
},
|
|
239
|
+
catch: () =>
|
|
240
|
+
// If we can't lstat, it's not a symlink (or doesn't exist)
|
|
241
|
+
false,
|
|
242
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(false))),
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Read the target of a symbolic link.
|
|
246
|
+
* Returns null if the file is not a symlink or doesn't exist.
|
|
247
|
+
*/
|
|
248
|
+
readSymlinkTarget: (path: string) =>
|
|
249
|
+
Effect.tryPromise({
|
|
250
|
+
try: async () => {
|
|
251
|
+
const stats = await lstat(path)
|
|
252
|
+
if (!stats.isSymbolicLink()) {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
return await readlink(path)
|
|
256
|
+
},
|
|
257
|
+
catch: () => null,
|
|
258
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(null))),
|
|
222
259
|
}),
|
|
223
260
|
},
|
|
224
261
|
) {}
|
package/src/types.ts
CHANGED
|
@@ -12,7 +12,11 @@ import { GitService } from "./services/GitService"
|
|
|
12
12
|
export interface Command {
|
|
13
13
|
name: string
|
|
14
14
|
description: string
|
|
15
|
-
run: (
|
|
15
|
+
run: (
|
|
16
|
+
args: string[],
|
|
17
|
+
options: Record<string, any>,
|
|
18
|
+
rawArgs?: string[],
|
|
19
|
+
) => Promise<void>
|
|
16
20
|
help?: string
|
|
17
21
|
}
|
|
18
22
|
|