@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 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 (args: string[], options: Record<string, any>) => {
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "1.5.0",
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
  ) {}
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: (args: string[], options: Record<string, any>) => Promise<void>
15
+ run: (
16
+ args: string[],
17
+ options: Record<string, any>,
18
+ rawArgs?: string[],
19
+ ) => Promise<void>
16
20
  help?: string
17
21
  }
18
22