@output.ai/cli 0.8.1 → 0.8.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.
@@ -1,10 +1,8 @@
1
1
  import { Command } from '@oclif/core';
2
- export default class Init extends Command {
2
+ export default class Update extends Command {
3
3
  static description: string;
4
4
  static examples: string[];
5
5
  static args: {};
6
- static flags: {
7
- force: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
8
- };
6
+ static flags: {};
9
7
  run(): Promise<void>;
10
8
  }
@@ -0,0 +1,29 @@
1
+ import { Command } from '@oclif/core';
2
+ import { ensureClaudePlugin } from '#services/coding_agents.js';
3
+ import { getErrorMessage, getErrorCode } from '#utils/error_utils.js';
4
+ export default class Update extends Command {
5
+ static description = 'Update Claude Code plugin for Output.ai';
6
+ static examples = [
7
+ '<%= config.bin %> <%= command.id %>'
8
+ ];
9
+ static args = {};
10
+ static flags = {};
11
+ async run() {
12
+ this.log('Updating Claude Code plugin...');
13
+ try {
14
+ await ensureClaudePlugin(process.cwd());
15
+ this.log('Claude Code plugin updated successfully!');
16
+ this.log('');
17
+ this.log('Updated:');
18
+ this.log(' - Registered marketplace: growthxai/output-claude-plugins');
19
+ this.log(' - Updated marketplace: outputai');
20
+ this.log(' - Installed plugin: outputai@outputai');
21
+ }
22
+ catch (error) {
23
+ if (getErrorCode(error) === 'EACCES') {
24
+ this.warn('Permission denied. Please check file permissions and try again.');
25
+ }
26
+ this.error(`Failed to update Claude Code plugin: ${getErrorMessage(error)}`);
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,84 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
+ import Update from './update.js';
4
+ import { ensureClaudePlugin } from '#services/coding_agents.js';
5
+ vi.mock('#services/coding_agents.js', () => ({
6
+ ensureClaudePlugin: vi.fn()
7
+ }));
8
+ describe('agents update', () => {
9
+ const createTestCommand = (args = []) => {
10
+ const cmd = new Update(args, {});
11
+ cmd.log = vi.fn();
12
+ cmd.warn = vi.fn();
13
+ cmd.error = vi.fn();
14
+ cmd.debug = vi.fn();
15
+ cmd.parse = vi.fn();
16
+ return cmd;
17
+ };
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ vi.mocked(ensureClaudePlugin).mockResolvedValue(undefined);
21
+ });
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+ });
25
+ describe('command structure', () => {
26
+ it('should have correct description', () => {
27
+ expect(Update.description).toBeDefined();
28
+ expect(Update.description).toContain('Update Claude Code plugin');
29
+ });
30
+ it('should have correct examples', () => {
31
+ expect(Update.examples).toBeDefined();
32
+ expect(Array.isArray(Update.examples)).toBe(true);
33
+ });
34
+ it('should have no required arguments', () => {
35
+ expect(Update.args).toBeDefined();
36
+ expect(Object.keys(Update.args)).toHaveLength(0);
37
+ });
38
+ it('should have no flags', () => {
39
+ expect(Update.flags).toBeDefined();
40
+ expect(Object.keys(Update.flags)).toHaveLength(0);
41
+ });
42
+ });
43
+ describe('successful execution', () => {
44
+ it('should call ensureClaudePlugin with process.cwd()', async () => {
45
+ const cmd = createTestCommand();
46
+ cmd.parse.mockResolvedValue({ flags: {}, args: {} });
47
+ await cmd.run();
48
+ expect(ensureClaudePlugin).toHaveBeenCalledWith(expect.any(String));
49
+ });
50
+ it('should display success messages', async () => {
51
+ const cmd = createTestCommand();
52
+ cmd.parse.mockResolvedValue({ flags: {}, args: {} });
53
+ await cmd.run();
54
+ expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('updated successfully'));
55
+ expect(cmd.log).toHaveBeenCalledWith('Updated:');
56
+ expect(cmd.log).toHaveBeenCalledWith(expect.stringContaining('marketplace'));
57
+ });
58
+ });
59
+ describe('error handling', () => {
60
+ it('should handle permission errors', async () => {
61
+ vi.mocked(ensureClaudePlugin).mockRejectedValue({ code: 'EACCES' });
62
+ const cmd = createTestCommand();
63
+ cmd.parse.mockResolvedValue({ flags: {}, args: {} });
64
+ await cmd.run();
65
+ expect(cmd.warn).toHaveBeenCalledWith(expect.stringContaining('Permission denied'));
66
+ expect(cmd.error).toHaveBeenCalled();
67
+ });
68
+ it('should handle general errors with message', async () => {
69
+ vi.mocked(ensureClaudePlugin).mockRejectedValue(new Error('Something went wrong'));
70
+ const cmd = createTestCommand();
71
+ cmd.parse.mockResolvedValue({ flags: {}, args: {} });
72
+ await cmd.run();
73
+ expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update Claude Code plugin'));
74
+ expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Something went wrong'));
75
+ });
76
+ it('should handle Claude CLI not found error', async () => {
77
+ vi.mocked(ensureClaudePlugin).mockRejectedValue(new Error('Claude CLI not found. Please install Claude Code CLI and ensure \'claude\' is in your PATH.'));
78
+ const cmd = createTestCommand();
79
+ cmd.parse.mockResolvedValue({ flags: {}, args: {} });
80
+ await cmd.run();
81
+ expect(cmd.error).toHaveBeenCalledWith(expect.stringContaining('Claude CLI not found'));
82
+ });
83
+ });
84
+ });
@@ -5,6 +5,7 @@ import logUpdate from 'log-update';
5
5
  import { validateDockerEnvironment, startDockerCompose, stopDockerCompose, getServiceStatus, DockerComposeConfigNotFoundError, getDefaultDockerComposePath, SERVICE_HEALTH, SERVICE_STATE } from '#services/docker.js';
6
6
  import { getErrorMessage } from '#utils/error_utils.js';
7
7
  import { getDevSuccessMessage } from '#services/messages.js';
8
+ import { ensureClaudePlugin } from '#services/coding_agents.js';
8
9
  const ANSI = {
9
10
  RESET: '\x1b[0m',
10
11
  DIM: '\x1b[2m',
@@ -96,6 +97,8 @@ export default class Dev extends Command {
96
97
  dockerProcess = null;
97
98
  async run() {
98
99
  const { flags } = await this.parse(Dev);
100
+ // Ensure Claude plugin is configured (fire-and-forget, silent)
101
+ ensureClaudePlugin(process.cwd(), { silent: true }).catch(() => { });
99
102
  validateDockerEnvironment();
100
103
  const dockerComposePath = flags['compose-file'] ?
101
104
  path.resolve(process.cwd(), flags['compose-file']) :
@@ -2,7 +2,11 @@
2
2
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
3
  import fs from 'node:fs/promises';
4
4
  import * as dockerService from '#services/docker.js';
5
+ import * as codingAgentsService from '#services/coding_agents.js';
5
6
  import Dev from './index.js';
7
+ vi.mock('#services/coding_agents.js', () => ({
8
+ ensureClaudePlugin: vi.fn().mockResolvedValue(undefined)
9
+ }));
6
10
  vi.mock('#services/docker.js', () => ({
7
11
  validateDockerEnvironment: vi.fn(),
8
12
  startDockerCompose: vi.fn(),
@@ -48,6 +52,8 @@ describe('dev command', () => {
48
52
  vi.mocked(dockerService.startDockerCompose).mockResolvedValue(createMockDockerProcess());
49
53
  // By default, fs.access succeeds (file exists)
50
54
  vi.mocked(fs).access.mockResolvedValue(undefined);
55
+ // By default, ensureClaudePlugin succeeds
56
+ vi.mocked(codingAgentsService.ensureClaudePlugin).mockResolvedValue(undefined);
51
57
  });
52
58
  afterEach(() => {
53
59
  vi.restoreAllMocks();
@@ -128,6 +134,36 @@ describe('dev command', () => {
128
134
  expect(vi.mocked(dockerService.validateDockerEnvironment)).toBeDefined();
129
135
  });
130
136
  });
137
+ describe('Claude plugin update', () => {
138
+ it('should call ensureClaudePlugin on startup', async () => {
139
+ const cmd = new Dev([], {});
140
+ cmd.log = vi.fn();
141
+ cmd.error = vi.fn();
142
+ Object.defineProperty(cmd, 'parse', {
143
+ value: vi.fn().mockResolvedValue({ flags: { 'no-watch': false, 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} }),
144
+ configurable: true
145
+ });
146
+ const runPromise = cmd.run();
147
+ await new Promise(resolve => setImmediate(resolve));
148
+ expect(codingAgentsService.ensureClaudePlugin).toHaveBeenCalledWith(process.cwd(), { silent: true });
149
+ runPromise.catch(() => { });
150
+ });
151
+ it('should not block dev if ensureClaudePlugin fails', async () => {
152
+ vi.mocked(codingAgentsService.ensureClaudePlugin).mockRejectedValue(new Error('Plugin update failed'));
153
+ const cmd = new Dev([], {});
154
+ cmd.log = vi.fn();
155
+ cmd.error = vi.fn();
156
+ Object.defineProperty(cmd, 'parse', {
157
+ value: vi.fn().mockResolvedValue({ flags: { 'no-watch': false, 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} }),
158
+ configurable: true
159
+ });
160
+ const runPromise = cmd.run();
161
+ await new Promise(resolve => setImmediate(resolve));
162
+ // Docker compose should still be called even if plugin update fails
163
+ expect(dockerService.startDockerCompose).toHaveBeenCalled();
164
+ runPromise.catch(() => { });
165
+ });
166
+ });
131
167
  describe('watch functionality', () => {
132
168
  it('should enable watch by default', async () => {
133
169
  const cmd = new Dev([], {});
@@ -87,7 +87,7 @@ function displaySystemValidationWarnings(validation) {
87
87
  ux.warn(`Missing required claude-code slash command: /${command}`);
88
88
  });
89
89
  ux.warn('Your claude-code agent is missing key configurations, it may not behave as expected.');
90
- ux.warn('Please run "npx output agents init" to fix this.');
90
+ ux.warn('Please run "npx output agents update" to fix this.');
91
91
  }
92
92
  function applyDefaultOptions(options) {
93
93
  return {
@@ -6,31 +6,31 @@ export interface InitOptions {
6
6
  projectRoot: string;
7
7
  force: boolean;
8
8
  }
9
- /**
10
- * Get the full path to the agent configuration directory
11
- */
12
- export declare function getAgentConfigDir(projectRoot: string): string;
13
- /**
14
- * Check if .outputai directory exists
15
- */
16
- export declare function checkAgentConfigDirExists(projectRoot: string): Promise<boolean>;
9
+ interface EnsureClaudePluginOptions {
10
+ silent?: boolean;
11
+ }
17
12
  export declare function checkAgentStructure(projectRoot: string): Promise<StructureCheckResult>;
18
13
  /**
19
14
  * Prepare template variables for file generation
20
15
  */
21
16
  export declare function prepareTemplateVariables(): Record<string, string>;
17
+ /**
18
+ * Ensure Claude Code plugin is configured
19
+ * Registers marketplace, updates it, and installs the plugin
20
+ */
21
+ export declare function ensureClaudePlugin(projectRoot: string, options?: EnsureClaudePluginOptions): Promise<void>;
22
22
  /**
23
23
  * Initialize agent configuration files and register Claude Code plugin
24
- * Creates 3 files:
25
- * - .outputai/AGENTS.md (from template with Handlebars processing)
24
+ * Creates:
26
25
  * - .claude/settings.json (static JSON)
27
- * - CLAUDE.md symlink to .outputai/AGENTS.md
26
+ * - CLAUDE.md (from template - user-customizable file)
28
27
  * Then runs Claude CLI commands to register the plugin marketplace and install the plugin
29
28
  */
30
29
  export declare function initializeAgentConfig(options: InitOptions): Promise<void>;
31
30
  /**
32
- * Ensure OutputAI system is initialized by invoking agents init if needed
31
+ * Ensure OutputAI system is initialized
33
32
  * Creates configuration files and registers Claude Code plugin
34
33
  * @param projectRoot - Root directory of the project
35
34
  */
36
35
  export declare function ensureOutputAISystem(projectRoot: string): Promise<void>;
36
+ export {};
@@ -8,30 +8,15 @@ import path from 'node:path';
8
8
  import { join } from 'node:path';
9
9
  import { ux } from '@oclif/core';
10
10
  import { confirm } from '@inquirer/prompts';
11
- import { config } from '#config.js';
11
+ import debugFactory from 'debug';
12
12
  import { getTemplateDir } from '#utils/paths.js';
13
13
  import { executeClaudeCommand } from '#utils/claude.js';
14
14
  import { processTemplate } from '#utils/template.js';
15
15
  import { ClaudePluginError, UserCancelledError } from '#types/errors.js';
16
+ const debug = debugFactory('output-cli:agent');
16
17
  const EXPECTED_MARKETPLACE_REPO = 'growthxai/output-claude-plugins';
17
- /**
18
- * Get the full path to the agent configuration directory
19
- */
20
- export function getAgentConfigDir(projectRoot) {
21
- return join(projectRoot, config.agentConfigDir);
22
- }
23
- /**
24
- * Check if .outputai directory exists
25
- */
26
- export async function checkAgentConfigDirExists(projectRoot) {
27
- const agentConfigDir = getAgentConfigDir(projectRoot);
28
- try {
29
- await access(agentConfigDir);
30
- return true;
31
- }
32
- catch {
33
- return false;
34
- }
18
+ function createLogger(silent) {
19
+ return silent ? debug : (msg) => ux.stdout(ux.colorize('gray', msg));
35
20
  }
36
21
  async function fileExists(filePath) {
37
22
  try {
@@ -59,7 +44,6 @@ async function validateSettingsJson(projectRoot) {
59
44
  }
60
45
  }
61
46
  export async function checkAgentStructure(projectRoot) {
62
- const outputaiDirExists = await checkAgentConfigDirExists(projectRoot);
63
47
  const settingsValid = await validateSettingsJson(projectRoot);
64
48
  const claudeMdExists = await fileExists(join(projectRoot, 'CLAUDE.md'));
65
49
  if (!settingsValid) {
@@ -68,11 +52,8 @@ export async function checkAgentStructure(projectRoot) {
68
52
  if (!claudeMdExists) {
69
53
  ux.warn('CLAUDE.md missing.');
70
54
  }
71
- if (!outputaiDirExists) {
72
- ux.warn('.outputai/ directory missing.');
73
- }
74
- const isComplete = outputaiDirExists && settingsValid && claudeMdExists;
75
- const needsInit = !outputaiDirExists || !settingsValid;
55
+ const isComplete = settingsValid && claudeMdExists;
56
+ const needsInit = !settingsValid;
76
57
  return { isComplete, needsInit };
77
58
  }
78
59
  /**
@@ -122,51 +103,6 @@ async function createStaticFile(templateSubpath, output) {
122
103
  await fs.writeFile(output, content, 'utf-8');
123
104
  ux.stdout(ux.colorize('gray', `Created file: ${output}`));
124
105
  }
125
- /**
126
- * Create a symlink, falling back to copying if symlinks are not supported
127
- */
128
- async function createSymlink(source, target, projectRoot) {
129
- try {
130
- if (await fileExists(target)) {
131
- await fs.unlink(target);
132
- }
133
- // Resolve source path relative to project root if it's relative
134
- const resolvedSource = path.isAbsolute(source) ?
135
- source :
136
- path.join(projectRoot, source);
137
- // Calculate relative path from target directory to resolved source
138
- const relativePath = path.relative(path.dirname(target), resolvedSource);
139
- await fs.symlink(relativePath, target);
140
- ux.stdout(ux.colorize('gray', `Created symlink: ${target} -> ${source}`));
141
- }
142
- catch (error) {
143
- const code = error.code;
144
- if (code === 'ENOTSUP' || code === 'EPERM') {
145
- ux.stdout(ux.colorize('gray', `Symlinks not supported, creating copy: ${target}`));
146
- const resolvedSource = path.isAbsolute(source) ?
147
- source :
148
- path.join(projectRoot, source);
149
- const content = await fs.readFile(resolvedSource, 'utf-8');
150
- await fs.writeFile(target, content, 'utf-8');
151
- return;
152
- }
153
- throw error;
154
- }
155
- }
156
- /**
157
- * Create .outputai/AGENTS.md file from template
158
- */
159
- async function createAgentsMdFile(projectRoot, force, variables) {
160
- const outputaiDir = join(projectRoot, config.agentConfigDir);
161
- await ensureDirectoryExists(outputaiDir);
162
- const agentsMdPath = join(outputaiDir, 'AGENTS.md');
163
- if (force || !await fileExists(agentsMdPath)) {
164
- await createFromTemplate('dotoutputai/AGENTS.md.template', agentsMdPath, variables);
165
- }
166
- else {
167
- ux.warn(`File already exists: ${config.agentConfigDir}/AGENTS.md (use --force to overwrite)`);
168
- }
169
- }
170
106
  /**
171
107
  * Create .claude/settings.json file from template
172
108
  */
@@ -182,13 +118,12 @@ async function createSettingsFile(projectRoot, force) {
182
118
  }
183
119
  }
184
120
  /**
185
- * Create CLAUDE.md symlink pointing to .outputai/AGENTS.md
186
- * Only checks if CLAUDE.md exists, not AGENTS.md - developers can remove AGENTS.md freely
121
+ * Create CLAUDE.md file from template
187
122
  */
188
- async function createClaudeMdSymlink(projectRoot, force) {
123
+ async function createClaudeMdFile(projectRoot, force, variables) {
189
124
  const claudeMdPath = join(projectRoot, 'CLAUDE.md');
190
125
  if (force || !await fileExists(claudeMdPath)) {
191
- await createSymlink(`${config.agentConfigDir}/AGENTS.md`, claudeMdPath, projectRoot);
126
+ await createFromTemplate('CLAUDE.md.template', claudeMdPath, variables);
192
127
  }
193
128
  else {
194
129
  ux.warn('File already exists: CLAUDE.md (use --force to overwrite)');
@@ -198,10 +133,15 @@ async function createClaudeMdSymlink(projectRoot, force) {
198
133
  * Handle Claude plugin command errors with user confirmation to proceed
199
134
  * @param error - The error that occurred
200
135
  * @param commandName - Name of the command that failed
136
+ * @param silent - If true, log to debug and re-throw without user confirmation
201
137
  * @throws UserCancelledError if user declines to proceed or presses Ctrl+C
202
138
  */
203
- async function handlePluginError(error, commandName) {
139
+ async function handlePluginError(error, commandName, silent = false) {
204
140
  const pluginError = new ClaudePluginError(commandName, error instanceof Error ? error : undefined);
141
+ if (silent) {
142
+ debug('Plugin error: %s', pluginError.message);
143
+ throw error;
144
+ }
205
145
  ux.warn(pluginError.message);
206
146
  try {
207
147
  const shouldProceed = await confirm({
@@ -225,54 +165,62 @@ async function handlePluginError(error, commandName) {
225
165
  /**
226
166
  * Register and update the OutputAI plugin marketplace
227
167
  */
228
- async function registerPluginMarketplace(projectRoot) {
229
- ux.stdout(ux.colorize('gray', 'Registering plugin marketplace...'));
168
+ async function registerPluginMarketplace(projectRoot, silent = false) {
169
+ const log = createLogger(silent);
170
+ log('Registering plugin marketplace...');
230
171
  try {
231
172
  await executeClaudeCommand(['plugin', 'marketplace', 'add', 'growthxai/output-claude-plugins'], projectRoot, { ignoreFailure: true });
232
173
  }
233
174
  catch (error) {
234
- await handlePluginError(error, 'plugin marketplace add');
175
+ await handlePluginError(error, 'plugin marketplace add', silent);
235
176
  return;
236
177
  }
237
- ux.stdout(ux.colorize('gray', 'Updating plugin marketplace...'));
178
+ log('Updating plugin marketplace...');
238
179
  try {
239
180
  await executeClaudeCommand(['plugin', 'marketplace', 'update', 'outputai'], projectRoot);
240
181
  }
241
182
  catch (error) {
242
- await handlePluginError(error, 'plugin marketplace update outputai');
183
+ await handlePluginError(error, 'plugin marketplace update outputai', silent);
243
184
  }
244
185
  }
245
186
  /**
246
187
  * Install the OutputAI plugin
247
188
  */
248
- async function installOutputAIPlugin(projectRoot) {
249
- ux.stdout(ux.colorize('gray', 'Installing OutputAI plugin...'));
189
+ async function installOutputAIPlugin(projectRoot, silent = false) {
190
+ const log = createLogger(silent);
191
+ log('Installing OutputAI plugin...');
250
192
  try {
251
193
  await executeClaudeCommand(['plugin', 'install', 'outputai@outputai', '--scope', 'project'], projectRoot);
252
194
  }
253
195
  catch (error) {
254
- await handlePluginError(error, 'plugin install outputai@outputai');
196
+ await handlePluginError(error, 'plugin install outputai@outputai', silent);
255
197
  }
256
198
  }
199
+ /**
200
+ * Ensure Claude Code plugin is configured
201
+ * Registers marketplace, updates it, and installs the plugin
202
+ */
203
+ export async function ensureClaudePlugin(projectRoot, options = {}) {
204
+ const { silent = false } = options;
205
+ await registerPluginMarketplace(projectRoot, silent);
206
+ await installOutputAIPlugin(projectRoot, silent);
207
+ }
257
208
  /**
258
209
  * Initialize agent configuration files and register Claude Code plugin
259
- * Creates 3 files:
260
- * - .outputai/AGENTS.md (from template with Handlebars processing)
210
+ * Creates:
261
211
  * - .claude/settings.json (static JSON)
262
- * - CLAUDE.md symlink to .outputai/AGENTS.md
212
+ * - CLAUDE.md (from template - user-customizable file)
263
213
  * Then runs Claude CLI commands to register the plugin marketplace and install the plugin
264
214
  */
265
215
  export async function initializeAgentConfig(options) {
266
216
  const { projectRoot, force } = options;
267
217
  const variables = prepareTemplateVariables();
268
- await createAgentsMdFile(projectRoot, force, variables);
269
218
  await createSettingsFile(projectRoot, force);
270
- await createClaudeMdSymlink(projectRoot, force);
271
- await registerPluginMarketplace(projectRoot);
272
- await installOutputAIPlugin(projectRoot);
219
+ await createClaudeMdFile(projectRoot, force, variables);
220
+ await ensureClaudePlugin(projectRoot);
273
221
  }
274
222
  /**
275
- * Ensure OutputAI system is initialized by invoking agents init if needed
223
+ * Ensure OutputAI system is initialized
276
224
  * Creates configuration files and registers Claude Code plugin
277
225
  * @param projectRoot - Root directory of the project
278
226
  */
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
- import { checkAgentConfigDirExists, checkAgentStructure, getAgentConfigDir, prepareTemplateVariables, initializeAgentConfig, ensureOutputAISystem } from './coding_agents.js';
2
+ import { checkAgentStructure, prepareTemplateVariables, initializeAgentConfig, ensureOutputAISystem, ensureClaudePlugin } from './coding_agents.js';
3
3
  import { access } from 'node:fs/promises';
4
4
  import fs from 'node:fs/promises';
5
5
  vi.mock('node:fs/promises');
@@ -26,35 +26,17 @@ describe('coding_agents service', () => {
26
26
  beforeEach(() => {
27
27
  vi.clearAllMocks();
28
28
  });
29
- describe('getAgentConfigDir', () => {
30
- it('should return the correct path to .outputai directory', () => {
31
- const result = getAgentConfigDir('/test/project');
32
- expect(result).toBe('/test/project/.outputai');
33
- });
34
- });
35
- describe('checkAgentConfigDirExists', () => {
36
- it('should return true when .outputai directory exists', async () => {
37
- vi.mocked(access).mockResolvedValue(undefined);
38
- const result = await checkAgentConfigDirExists('/test/project');
39
- expect(result).toBe(true);
40
- expect(access).toHaveBeenCalledWith('/test/project/.outputai');
41
- });
42
- it('should return false when .outputai directory does not exist', async () => {
43
- vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
44
- const result = await checkAgentConfigDirExists('/test/project');
45
- expect(result).toBe(false);
46
- });
47
- });
48
29
  describe('checkAgentStructure', () => {
49
- it('should return needsInit true when directory does not exist', async () => {
30
+ it('should return needsInit true when settings.json does not exist', async () => {
50
31
  vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
32
+ vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' });
51
33
  const result = await checkAgentStructure('/test/project');
52
34
  expect(result).toEqual({
53
35
  isComplete: false,
54
36
  needsInit: true
55
37
  });
56
38
  });
57
- it('should return complete when all files exist with valid settings', async () => {
39
+ it('should return complete when settings and CLAUDE.md exist with valid configuration', async () => {
58
40
  vi.mocked(access).mockResolvedValue(undefined);
59
41
  vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({
60
42
  extraKnownMarketplaces: {
@@ -113,19 +95,6 @@ describe('coding_agents service', () => {
113
95
  expect(result.isComplete).toBe(false);
114
96
  expect(result.needsInit).toBe(true);
115
97
  });
116
- it('should return needsInit true when settings.json does not exist', async () => {
117
- vi.mocked(access).mockImplementation(async (path) => {
118
- const pathStr = path.toString();
119
- if (pathStr.endsWith('.claude/settings.json')) {
120
- throw { code: 'ENOENT' };
121
- }
122
- return undefined;
123
- });
124
- vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' });
125
- const result = await checkAgentStructure('/test/project');
126
- expect(result.isComplete).toBe(false);
127
- expect(result.needsInit).toBe(true);
128
- });
129
98
  });
130
99
  describe('prepareTemplateVariables', () => {
131
100
  it('should return template variables with formatted date', () => {
@@ -141,19 +110,18 @@ describe('coding_agents service', () => {
141
110
  vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
142
111
  vi.mocked(fs.readFile).mockResolvedValue('template content');
143
112
  vi.mocked(fs.writeFile).mockResolvedValue(undefined);
144
- vi.mocked(fs.symlink).mockResolvedValue(undefined);
145
113
  });
146
- it('should create exactly 3 outputs: AGENTS.md, settings.json, and CLAUDE.md symlink', async () => {
114
+ it('should create exactly 2 outputs: settings.json and CLAUDE.md file', async () => {
147
115
  await initializeAgentConfig({
148
116
  projectRoot: '/test/project',
149
117
  force: false
150
118
  });
151
- expect(fs.mkdir).toHaveBeenCalledTimes(2);
152
- expect(fs.mkdir).toHaveBeenCalledWith('/test/project/.outputai', expect.objectContaining({ recursive: true }));
119
+ expect(fs.mkdir).toHaveBeenCalledTimes(1);
153
120
  expect(fs.mkdir).toHaveBeenCalledWith('/test/project/.claude', expect.objectContaining({ recursive: true }));
154
- expect(fs.writeFile).toHaveBeenCalledWith('/test/project/.outputai/AGENTS.md', expect.any(String), 'utf-8');
155
121
  expect(fs.writeFile).toHaveBeenCalledWith('/test/project/.claude/settings.json', expect.any(String), 'utf-8');
156
- expect(fs.symlink).toHaveBeenCalledTimes(1);
122
+ expect(fs.writeFile).toHaveBeenCalledWith('/test/project/CLAUDE.md', expect.any(String), 'utf-8');
123
+ // No symlink should be created - CLAUDE.md is now a real file
124
+ expect(fs.symlink).not.toHaveBeenCalled();
157
125
  });
158
126
  it('should skip existing files when force is false', async () => {
159
127
  vi.mocked(access).mockResolvedValue(undefined);
@@ -162,7 +130,6 @@ describe('coding_agents service', () => {
162
130
  force: false
163
131
  });
164
132
  expect(fs.writeFile).not.toHaveBeenCalled();
165
- expect(fs.symlink).not.toHaveBeenCalled();
166
133
  });
167
134
  it('should overwrite existing files when force is true', async () => {
168
135
  vi.mocked(access).mockResolvedValue(undefined);
@@ -173,15 +140,40 @@ describe('coding_agents service', () => {
173
140
  });
174
141
  expect(fs.writeFile).toHaveBeenCalled();
175
142
  });
176
- it('should handle symlink errors by falling back to copy', async () => {
177
- vi.mocked(fs.symlink).mockRejectedValue({ code: 'ENOTSUP' });
178
- await initializeAgentConfig({
179
- projectRoot: '/test/project',
180
- force: false
181
- });
182
- const writeFileCalls = vi.mocked(fs.writeFile).mock.calls;
183
- const claudeMdCalls = writeFileCalls.filter(call => call[0].toString().includes('CLAUDE.md'));
184
- expect(claudeMdCalls.length).toBe(1);
143
+ });
144
+ describe('ensureClaudePlugin', () => {
145
+ beforeEach(() => {
146
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
147
+ vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
148
+ vi.mocked(fs.readFile).mockResolvedValue('template content');
149
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined);
150
+ });
151
+ it('should call registerPluginMarketplace and installOutputAIPlugin', async () => {
152
+ const { executeClaudeCommand } = await import('../utils/claude.js');
153
+ await ensureClaudePlugin('/test/project');
154
+ expect(executeClaudeCommand).toHaveBeenCalledWith(['plugin', 'marketplace', 'add', 'growthxai/output-claude-plugins'], '/test/project', { ignoreFailure: true });
155
+ expect(executeClaudeCommand).toHaveBeenCalledWith(['plugin', 'marketplace', 'update', 'outputai'], '/test/project');
156
+ expect(executeClaudeCommand).toHaveBeenCalledWith(['plugin', 'install', 'outputai@outputai', '--scope', 'project'], '/test/project');
157
+ });
158
+ it('should show error and prompt user when plugin commands fail', async () => {
159
+ const { executeClaudeCommand } = await import('../utils/claude.js');
160
+ const { confirm } = await import('@inquirer/prompts');
161
+ vi.mocked(executeClaudeCommand)
162
+ .mockResolvedValueOnce(undefined) // marketplace add
163
+ .mockRejectedValueOnce(new Error('Plugin update failed')); // marketplace update
164
+ vi.mocked(confirm).mockResolvedValue(true);
165
+ await expect(ensureClaudePlugin('/test/project')).resolves.not.toThrow();
166
+ expect(confirm).toHaveBeenCalledWith(expect.objectContaining({
167
+ message: expect.stringContaining('proceed')
168
+ }));
169
+ });
170
+ it('should allow user to proceed without plugin setup if they confirm', async () => {
171
+ const { executeClaudeCommand } = await import('../utils/claude.js');
172
+ const { confirm } = await import('@inquirer/prompts');
173
+ vi.mocked(executeClaudeCommand)
174
+ .mockRejectedValue(new Error('All plugin commands fail'));
175
+ vi.mocked(confirm).mockResolvedValue(true);
176
+ await expect(ensureClaudePlugin('/test/project')).resolves.not.toThrow();
185
177
  });
186
178
  });
187
179
  describe('ensureOutputAISystem', () => {
@@ -190,7 +182,6 @@ describe('coding_agents service', () => {
190
182
  vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
191
183
  vi.mocked(fs.readFile).mockResolvedValue('template content');
192
184
  vi.mocked(fs.writeFile).mockResolvedValue(undefined);
193
- vi.mocked(fs.symlink).mockResolvedValue(undefined);
194
185
  });
195
186
  it('should return immediately when agent structure is complete', async () => {
196
187
  vi.mocked(access).mockResolvedValue(undefined);
@@ -205,12 +196,6 @@ describe('coding_agents service', () => {
205
196
  await ensureOutputAISystem('/test/project');
206
197
  expect(fs.mkdir).not.toHaveBeenCalled();
207
198
  });
208
- it('should auto-initialize when directory does not exist', async () => {
209
- vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
210
- await ensureOutputAISystem('/test/project');
211
- expect(fs.mkdir).toHaveBeenCalled();
212
- expect(fs.writeFile).toHaveBeenCalled();
213
- });
214
199
  it('should auto-initialize when settings.json is invalid', async () => {
215
200
  vi.mocked(access).mockResolvedValue(undefined);
216
201
  vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({
@@ -231,7 +216,6 @@ describe('coding_agents service', () => {
231
216
  vi.mocked(access).mockRejectedValue({ code: 'ENOENT' });
232
217
  vi.mocked(fs.readFile).mockResolvedValue('template content');
233
218
  vi.mocked(fs.writeFile).mockResolvedValue(undefined);
234
- vi.mocked(fs.symlink).mockResolvedValue(undefined);
235
219
  });
236
220
  it('should show error and prompt user when registerPluginMarketplace fails', async () => {
237
221
  const { executeClaudeCommand } = await import('../utils/claude.js');