@markjaquith/agency 0.5.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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/cli.ts +569 -0
  4. package/index.ts +1 -0
  5. package/package.json +65 -0
  6. package/src/commands/base.test.ts +198 -0
  7. package/src/commands/base.ts +198 -0
  8. package/src/commands/clean.test.ts +299 -0
  9. package/src/commands/clean.ts +320 -0
  10. package/src/commands/emit.test.ts +412 -0
  11. package/src/commands/emit.ts +521 -0
  12. package/src/commands/emitted.test.ts +226 -0
  13. package/src/commands/emitted.ts +57 -0
  14. package/src/commands/init.test.ts +311 -0
  15. package/src/commands/init.ts +140 -0
  16. package/src/commands/merge.test.ts +365 -0
  17. package/src/commands/merge.ts +253 -0
  18. package/src/commands/pull.test.ts +385 -0
  19. package/src/commands/pull.ts +205 -0
  20. package/src/commands/push.test.ts +394 -0
  21. package/src/commands/push.ts +346 -0
  22. package/src/commands/save.test.ts +247 -0
  23. package/src/commands/save.ts +162 -0
  24. package/src/commands/source.test.ts +195 -0
  25. package/src/commands/source.ts +72 -0
  26. package/src/commands/status.test.ts +489 -0
  27. package/src/commands/status.ts +258 -0
  28. package/src/commands/switch.test.ts +194 -0
  29. package/src/commands/switch.ts +84 -0
  30. package/src/commands/task-branching.test.ts +334 -0
  31. package/src/commands/task-edit.test.ts +141 -0
  32. package/src/commands/task-main.test.ts +872 -0
  33. package/src/commands/task.ts +712 -0
  34. package/src/commands/tasks.test.ts +335 -0
  35. package/src/commands/tasks.ts +155 -0
  36. package/src/commands/template-delete.test.ts +178 -0
  37. package/src/commands/template-delete.ts +98 -0
  38. package/src/commands/template-list.test.ts +135 -0
  39. package/src/commands/template-list.ts +87 -0
  40. package/src/commands/template-view.test.ts +158 -0
  41. package/src/commands/template-view.ts +86 -0
  42. package/src/commands/template.test.ts +32 -0
  43. package/src/commands/template.ts +96 -0
  44. package/src/commands/use.test.ts +87 -0
  45. package/src/commands/use.ts +97 -0
  46. package/src/commands/work.test.ts +462 -0
  47. package/src/commands/work.ts +193 -0
  48. package/src/errors.ts +17 -0
  49. package/src/schemas.ts +33 -0
  50. package/src/services/AgencyMetadataService.ts +287 -0
  51. package/src/services/ClaudeService.test.ts +184 -0
  52. package/src/services/ClaudeService.ts +91 -0
  53. package/src/services/ConfigService.ts +115 -0
  54. package/src/services/FileSystemService.ts +222 -0
  55. package/src/services/GitService.ts +751 -0
  56. package/src/services/OpencodeService.ts +263 -0
  57. package/src/services/PromptService.ts +183 -0
  58. package/src/services/TemplateService.ts +75 -0
  59. package/src/test-utils.ts +362 -0
  60. package/src/types/native-exec.d.ts +8 -0
  61. package/src/types.ts +216 -0
  62. package/src/utils/colors.ts +178 -0
  63. package/src/utils/command.ts +17 -0
  64. package/src/utils/effect.ts +281 -0
  65. package/src/utils/exec.ts +48 -0
  66. package/src/utils/paths.ts +51 -0
  67. package/src/utils/pr-branch.test.ts +372 -0
  68. package/src/utils/pr-branch.ts +473 -0
  69. package/src/utils/process.ts +110 -0
  70. package/src/utils/spinner.ts +82 -0
  71. package/templates/AGENCY.md +20 -0
  72. package/templates/AGENTS.md +11 -0
  73. package/templates/CLAUDE.md +3 -0
  74. package/templates/TASK.md +5 -0
  75. package/templates/opencode.json +4 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Color highlighting utilities for CLI output
3
+ *
4
+ * This module provides centralized color management for highlighting
5
+ * meaningful values in CLI feedback. All highlight types use the same
6
+ * base color initially, but are separated into buckets to allow for
7
+ * future customization.
8
+ *
9
+ * Colors can be disabled via:
10
+ * - NO_COLOR environment variable (standard)
11
+ * - Setting colorsEnabled to false
12
+ */
13
+
14
+ // ANSI color codes
15
+ const RESET = "\x1b[0m"
16
+ const CYAN_BRIGHT = "\x1b[96m"
17
+ const GREEN = "\x1b[32m"
18
+ const YELLOW = "\x1b[33m"
19
+
20
+ /**
21
+ * Central color configuration
22
+ * All buckets use the same color initially (bright cyan)
23
+ * but can be easily changed independently in the future
24
+ */
25
+ const COLORS = {
26
+ branch: CYAN_BRIGHT,
27
+ template: CYAN_BRIGHT,
28
+ file: CYAN_BRIGHT,
29
+ setting: CYAN_BRIGHT,
30
+ value: CYAN_BRIGHT,
31
+ commit: CYAN_BRIGHT,
32
+ pattern: CYAN_BRIGHT,
33
+ remote: YELLOW,
34
+ } as const
35
+
36
+ /**
37
+ * Global flag to enable/disable colors
38
+ * Defaults to enabled unless NO_COLOR environment variable is set
39
+ */
40
+ let colorsEnabled = !process.env.NO_COLOR
41
+
42
+ /**
43
+ * Enable or disable color output
44
+ * @param enabled - Whether to enable colors
45
+ */
46
+ export function setColorsEnabled(enabled: boolean): void {
47
+ colorsEnabled = enabled
48
+ }
49
+
50
+ /**
51
+ * Check if colors are currently enabled
52
+ */
53
+ function areColorsEnabled(): boolean {
54
+ return colorsEnabled
55
+ }
56
+
57
+ /**
58
+ * Internal helper to apply color formatting
59
+ * Returns plain text if colors are disabled
60
+ */
61
+ function colorize(text: string, color: string): string {
62
+ if (!colorsEnabled) {
63
+ return text
64
+ }
65
+ return `${color}${text}${RESET}`
66
+ }
67
+
68
+ /**
69
+ * Highlight a branch name
70
+ * @example highlight.branch("main") -> "\x1b[96mmain\x1b[0m"
71
+ */
72
+ function branch(name: string): string {
73
+ return colorize(name, COLORS.branch)
74
+ }
75
+
76
+ /**
77
+ * Highlight a template name
78
+ * @example highlight.template("my-template") -> "\x1b[96mmy-template\x1b[0m"
79
+ */
80
+ function template(name: string): string {
81
+ return colorize(name, COLORS.template)
82
+ }
83
+
84
+ /**
85
+ * Highlight a file name or path
86
+ * @example highlight.file("AGENTS.md") -> "\x1b[96mAGENTS.md\x1b[0m"
87
+ */
88
+ function file(name: string): string {
89
+ return colorize(name, COLORS.file)
90
+ }
91
+
92
+ /**
93
+ * Highlight a setting name
94
+ * @example highlight.setting("agency.template") -> "\x1b[96magency.template\x1b[0m"
95
+ */
96
+ function setting(name: string): string {
97
+ return colorize(name, COLORS.setting)
98
+ }
99
+
100
+ /**
101
+ * Highlight a numeric value or count
102
+ * @example highlight.value("3") -> "\x1b[96m3\x1b[0m"
103
+ */
104
+ function value(val: string | number): string {
105
+ return colorize(String(val), COLORS.value)
106
+ }
107
+
108
+ /**
109
+ * Highlight a commit hash
110
+ * @example highlight.commit("abc123") -> "\x1b[96mabc123\x1b[0m"
111
+ */
112
+ function commit(hash: string): string {
113
+ return colorize(hash, COLORS.commit)
114
+ }
115
+
116
+ /**
117
+ * Highlight a pattern or placeholder
118
+ * @example highlight.pattern("{task}") -> "\x1b[96m{task}\x1b[0m"
119
+ */
120
+ function pattern(text: string): string {
121
+ return colorize(text, COLORS.pattern)
122
+ }
123
+
124
+ /**
125
+ * Highlight a git remote name
126
+ * @example highlight.remote("origin") -> "\x1b[33morigin\x1b[0m"
127
+ */
128
+ function remote(name: string): string {
129
+ return colorize(name, COLORS.remote)
130
+ }
131
+
132
+ /**
133
+ * Prepends a green checkmark to a message for success output
134
+ * Uses the same checkmark symbol and color as ora for consistency
135
+ * @param message - The message to prepend the checkmark to
136
+ * @example log(done(`Merged ${highlight.branch("main")}`))
137
+ * @example log(done("Operation completed"))
138
+ */
139
+ export function done(message: string): string {
140
+ const checkmark = colorsEnabled ? `\x1b[32m✔\x1b[0m` : "✔"
141
+ return `${checkmark} ${message}`
142
+ }
143
+
144
+ /**
145
+ * Prepends an info icon to a message for informational output
146
+ * @param message - The message to prepend the info icon to
147
+ * @example log(info("Skipping task description"))
148
+ * @example log(info(`File already exists`))
149
+ */
150
+ export function info(message: string): string {
151
+ return `ⓘ ${message}`
152
+ }
153
+
154
+ /**
155
+ * Returns "s" if count is not 1, empty string otherwise
156
+ * Useful for pluralizing words in messages
157
+ * @param count - The count to check
158
+ * @example `Committed ${count} file${plural(count)}` -> "Committed 1 file" or "Committed 3 files"
159
+ */
160
+ export function plural(count: number): string {
161
+ return count === 1 ? "" : "s"
162
+ }
163
+
164
+ /**
165
+ * Default export as namespace for convenient usage
166
+ * @example import highlight from "./colors"
167
+ * @example console.log(`Switched to ${highlight.branch("main")}`)
168
+ */
169
+ export default {
170
+ branch,
171
+ template,
172
+ file,
173
+ setting,
174
+ value,
175
+ commit,
176
+ pattern,
177
+ remote,
178
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Base options that all commands accept
3
+ */
4
+ export interface BaseCommandOptions {
5
+ readonly silent?: boolean
6
+ readonly verbose?: boolean
7
+ /**
8
+ * Working directory to use instead of process.cwd().
9
+ * Primarily used for testing to enable concurrent test execution.
10
+ */
11
+ readonly cwd?: string
12
+ /**
13
+ * Agency config directory to use instead of AGENCY_CONFIG_DIR env var.
14
+ * Primarily used for testing to enable concurrent test execution.
15
+ */
16
+ readonly configDir?: string
17
+ }
@@ -0,0 +1,281 @@
1
+ import { Effect } from "effect"
2
+ import { GitService } from "../services/GitService"
3
+ import { getBaseBranchFromMetadata } from "../types"
4
+ import highlight from "./colors"
5
+
6
+ /**
7
+ * Ensure a branch exists, failing with an error if it doesn't
8
+ */
9
+ export function ensureBranchExists(
10
+ gitRoot: string,
11
+ branch: string,
12
+ errorMessage?: string,
13
+ ) {
14
+ return Effect.gen(function* () {
15
+ const git = yield* GitService
16
+ const exists = yield* git.branchExists(gitRoot, branch)
17
+ if (!exists) {
18
+ return yield* Effect.fail(
19
+ new Error(
20
+ errorMessage ?? `Branch ${highlight.branch(branch)} does not exist`,
21
+ ),
22
+ )
23
+ }
24
+ })
25
+ }
26
+
27
+ /**
28
+ * Create logging functions based on options
29
+ */
30
+ export function createLoggers(options: {
31
+ readonly silent?: boolean
32
+ readonly verbose?: boolean
33
+ }) {
34
+ const { silent = false, verbose = false } = options
35
+ return {
36
+ log: silent ? () => {} : console.log,
37
+ verboseLog: verbose && !silent ? console.log : () => {},
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Ensure we're in a git repository and return the git root
43
+ * @param providedCwd - Optional working directory to use instead of process.cwd()
44
+ */
45
+ export function ensureGitRepo(providedCwd?: string) {
46
+ return Effect.gen(function* () {
47
+ const git = yield* GitService
48
+ const cwd = providedCwd ?? process.cwd()
49
+
50
+ const isGitRepo = yield* git.isInsideGitRepo(cwd)
51
+ if (!isGitRepo) {
52
+ return yield* Effect.fail(
53
+ new Error(
54
+ "Not in a git repository. Please run this command inside a git repo.",
55
+ ),
56
+ )
57
+ }
58
+
59
+ return yield* git.getGitRoot(cwd)
60
+ })
61
+ }
62
+
63
+ /**
64
+ * Get the configured template name for the current repository
65
+ */
66
+ export function getTemplateName(gitRoot: string) {
67
+ return Effect.flatMap(GitService, (git) =>
68
+ git.getGitConfig("agency.template", gitRoot),
69
+ )
70
+ }
71
+
72
+ /**
73
+ * Resolve base branch with fallback chain:
74
+ * 1. Explicitly provided base branch
75
+ * 2. Branch-specific base branch from agency.json
76
+ * 3. Repository-level default base branch from git config
77
+ * 4. Auto-detected from origin/HEAD or common branches
78
+ */
79
+ export function resolveBaseBranch(
80
+ gitRoot: string,
81
+ providedBaseBranch?: string,
82
+ ) {
83
+ return Effect.gen(function* () {
84
+ const git = yield* GitService
85
+
86
+ // If explicitly provided, use it
87
+ if (providedBaseBranch) {
88
+ yield* ensureBranchExists(gitRoot, providedBaseBranch)
89
+ return providedBaseBranch
90
+ }
91
+
92
+ // Check if we have a branch-specific base branch in agency.json
93
+ const savedBaseBranch = yield* Effect.tryPromise({
94
+ try: () => getBaseBranchFromMetadata(gitRoot),
95
+ catch: (error) =>
96
+ new Error(`Failed to get base branch from metadata: ${error}`),
97
+ })
98
+ if (savedBaseBranch) {
99
+ const exists = yield* git.branchExists(gitRoot, savedBaseBranch)
100
+ if (exists) {
101
+ return savedBaseBranch
102
+ }
103
+ }
104
+
105
+ // Check for repository-level default base branch in git config
106
+ const defaultBaseBranch = yield* git.getDefaultBaseBranchConfig(gitRoot)
107
+ if (defaultBaseBranch) {
108
+ const exists = yield* git.branchExists(gitRoot, defaultBaseBranch)
109
+ if (exists) {
110
+ return defaultBaseBranch
111
+ }
112
+ }
113
+
114
+ // Try to auto-detect the default remote branch
115
+ const defaultRemote = yield* git.getDefaultRemoteBranch(gitRoot)
116
+ if (defaultRemote) {
117
+ const exists = yield* git.branchExists(gitRoot, defaultRemote)
118
+ if (exists) {
119
+ return defaultRemote
120
+ }
121
+ }
122
+
123
+ // Try common base branches in order, using resolved remote
124
+ const remote = yield* git
125
+ .resolveRemote(gitRoot)
126
+ .pipe(Effect.catchAll(() => Effect.succeed(null)))
127
+
128
+ const commonBases: string[] = []
129
+ if (remote) {
130
+ commonBases.push(`${remote}/main`, `${remote}/master`)
131
+ }
132
+ commonBases.push("main", "master")
133
+
134
+ for (const base of commonBases) {
135
+ const exists = yield* git.branchExists(gitRoot, base)
136
+ if (exists) {
137
+ return base
138
+ }
139
+ }
140
+
141
+ // Could not auto-detect, require explicit specification
142
+ return yield* Effect.fail(
143
+ new Error(
144
+ "Could not auto-detect base branch. Please specify one explicitly with the --base-branch option or configure one with: agency base set <branch>",
145
+ ),
146
+ )
147
+ })
148
+ }
149
+
150
+ /**
151
+ * Get base branch from agency.json metadata as an Effect
152
+ */
153
+ export function getBaseBranchFromMetadataEffect(gitRoot: string) {
154
+ return Effect.tryPromise({
155
+ try: () => getBaseBranchFromMetadata(gitRoot),
156
+ catch: (error) =>
157
+ new Error(`Failed to get base branch from metadata: ${error}`),
158
+ })
159
+ }
160
+
161
+ /**
162
+ * Get base branch from agency.json on a specific branch without checking it out.
163
+ * Uses git show to read the file contents directly.
164
+ */
165
+ export function getBaseBranchFromBranch(gitRoot: string, branch: string) {
166
+ return Effect.gen(function* () {
167
+ const git = yield* GitService
168
+
169
+ // Use git show to read agency.json from the branch
170
+ const content = yield* git.getFileAtRef(gitRoot, branch, "agency.json")
171
+
172
+ if (!content) {
173
+ return null
174
+ }
175
+
176
+ // Parse the content
177
+ const data = yield* Effect.try({
178
+ try: () => JSON.parse(content),
179
+ catch: () => null,
180
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)))
181
+
182
+ if (!data || typeof data !== "object") {
183
+ return null
184
+ }
185
+
186
+ return (data as { baseBranch?: string }).baseBranch || null
187
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)))
188
+ }
189
+
190
+ /**
191
+ * Get the configured remote name with fallback to auto-detection
192
+ */
193
+ export function getRemoteName(gitRoot: string) {
194
+ return Effect.gen(function* () {
195
+ const git = yield* GitService
196
+
197
+ // Use the new centralized resolveRemote method
198
+ // This already handles config checking and auto-detection with smart precedence
199
+ const remote = yield* git.resolveRemote(gitRoot)
200
+
201
+ return remote
202
+ })
203
+ }
204
+
205
+ /**
206
+ * Execute an operation that may change branches, with automatic cleanup on interrupt.
207
+ * This ensures that if Ctrl-C is pressed during an operation that changes branches,
208
+ * the user is returned to their original branch.
209
+ *
210
+ * @param gitRoot - The git repository root
211
+ * @param operation - The Effect operation to run
212
+ * @returns The result of the operation
213
+ */
214
+ export function withBranchProtection<A, E, R>(
215
+ gitRoot: string,
216
+ operation: Effect.Effect<A, E, R>,
217
+ ) {
218
+ return Effect.gen(function* () {
219
+ const git = yield* GitService
220
+
221
+ // Store the original branch before any operations
222
+ const originalBranch = yield* git.getCurrentBranch(gitRoot)
223
+
224
+ // Set up SIGINT handler to restore branch on interrupt
225
+ let interrupted = false
226
+ const originalSigintHandler = process.listeners("SIGINT")
227
+
228
+ const cleanup = async () => {
229
+ if (interrupted) return
230
+ interrupted = true
231
+
232
+ // Restore original SIGINT handlers
233
+ process.removeAllListeners("SIGINT")
234
+ for (const handler of originalSigintHandler) {
235
+ process.on("SIGINT", handler as NodeJS.SignalsListener)
236
+ }
237
+
238
+ // Try to restore the original branch
239
+ try {
240
+ const currentBranch = await Effect.runPromise(
241
+ git
242
+ .getCurrentBranch(gitRoot)
243
+ .pipe(Effect.provide(GitService.Default)),
244
+ )
245
+
246
+ if (currentBranch !== originalBranch) {
247
+ await Effect.runPromise(
248
+ git
249
+ .checkoutBranch(gitRoot, originalBranch)
250
+ .pipe(Effect.provide(GitService.Default)),
251
+ )
252
+ console.error(`\nInterrupted. Restored to branch: ${originalBranch}`)
253
+ }
254
+ } catch {
255
+ console.error(
256
+ `\nInterrupted. Could not restore branch. You may need to run: git checkout ${originalBranch}`,
257
+ )
258
+ }
259
+
260
+ // Exit the process
261
+ process.exit(130) // Standard exit code for SIGINT
262
+ }
263
+
264
+ // Install our SIGINT handler
265
+ process.removeAllListeners("SIGINT")
266
+ process.on("SIGINT", cleanup)
267
+
268
+ // Run the operation
269
+ const result = yield* Effect.onExit(operation, () =>
270
+ Effect.sync(() => {
271
+ // Restore original SIGINT handlers when operation completes
272
+ process.removeAllListeners("SIGINT")
273
+ for (const handler of originalSigintHandler) {
274
+ process.on("SIGINT", handler as NodeJS.SignalsListener)
275
+ }
276
+ }),
277
+ )
278
+
279
+ return result
280
+ })
281
+ }
@@ -0,0 +1,48 @@
1
+ import { dlopen, FFIType, ptr } from "bun:ffi"
2
+
3
+ /**
4
+ * Native exec implementation using Bun FFI to call POSIX execvp.
5
+ * This completely replaces the current process with the specified command.
6
+ *
7
+ * IMPORTANT: This function will never return if successful. The process
8
+ * image is completely replaced with the new program.
9
+ *
10
+ * @param file - The program to execute (will be searched in PATH)
11
+ * @param args - Array of arguments (first should be the program name)
12
+ * @throws Error if exec fails (e.g., command not found)
13
+ */
14
+ export function execvp(file: string, args: string[]): never {
15
+ // Open libc to access execvp (platform-specific library paths)
16
+ const libcPath =
17
+ process.platform === "darwin" ? "/usr/lib/libSystem.B.dylib" : "libc.so.6"
18
+ const libc = dlopen(libcPath, {
19
+ execvp: {
20
+ args: [FFIType.cstring, FFIType.ptr],
21
+ returns: FFIType.int,
22
+ },
23
+ })
24
+
25
+ // execvp expects argv as a null-terminated array of char* pointers
26
+ // We need to convert our string array to C strings and create a pointer array
27
+ const cstrings = args.map((arg) => Buffer.from(arg + "\0"))
28
+ const ptrs = new BigUint64Array(args.length + 1)
29
+
30
+ // Fill the pointer array with addresses of our C strings
31
+ for (let i = 0; i < args.length; i++) {
32
+ const buf = cstrings[i]
33
+ if (buf) {
34
+ ptrs[i] = BigInt(ptr(buf))
35
+ }
36
+ }
37
+ // Null-terminate the pointer array
38
+ ptrs[args.length] = 0n
39
+
40
+ // Call execvp - this will replace the current process if successful
41
+ const fileBuffer = Buffer.from(file + "\0")
42
+ const result = libc.symbols.execvp(ptr(fileBuffer), ptr(ptrs))
43
+
44
+ // If we reach here, exec failed
45
+ throw new Error(
46
+ `execvp failed with code ${result}: Unable to execute '${file}'`,
47
+ )
48
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Utilities for resolving agency configuration paths.
3
+ */
4
+
5
+ import { homedir } from "node:os"
6
+ import { join } from "node:path"
7
+
8
+ /**
9
+ * Get the agency configuration directory.
10
+ * Defaults to ~/.config/agency, can be overridden via AGENCY_CONFIG_DIR env var.
11
+ * @param override - Optional override for the config directory (used in testing)
12
+ */
13
+ export function getAgencyConfigDir(override?: string): string {
14
+ return (
15
+ override ||
16
+ process.env.AGENCY_CONFIG_DIR ||
17
+ join(homedir(), ".config", "agency")
18
+ )
19
+ }
20
+
21
+ /**
22
+ * Get the path to the agency config file (agency.json).
23
+ * Can be overridden via AGENCY_CONFIG_PATH env var.
24
+ * @param configDir - Optional config directory override
25
+ */
26
+ export function getAgencyConfigPath(configDir?: string): string {
27
+ return (
28
+ process.env.AGENCY_CONFIG_PATH ||
29
+ join(getAgencyConfigDir(configDir), "agency.json")
30
+ )
31
+ }
32
+
33
+ /**
34
+ * Get the templates directory path.
35
+ * @param configDir - Optional config directory override
36
+ */
37
+ export function getTemplatesDir(configDir?: string): string {
38
+ return join(getAgencyConfigDir(configDir), "templates")
39
+ }
40
+
41
+ /**
42
+ * Get the path to a specific template directory.
43
+ * @param templateName - Name of the template
44
+ * @param configDir - Optional config directory override
45
+ */
46
+ export function getTemplateDir(
47
+ templateName: string,
48
+ configDir?: string,
49
+ ): string {
50
+ return join(getTemplatesDir(configDir), templateName)
51
+ }