@markjaquith/agency 1.5.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Manages personal agents files",
5
5
  "license": "MIT",
6
6
  "author": "Mark Jaquith",
@@ -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
- return baseFiles
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
- return expandedFiles
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 { mkdir, copyFile as fsCopyFile, unlink } from "node:fs/promises"
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
  ) {}