@syntesseraai/opencode-feature-factory 0.6.7 → 0.6.8

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/bin/ff-deploy.js CHANGED
@@ -51,6 +51,40 @@ const DEFAULT_MCP_SERVERS = {
51
51
  },
52
52
  };
53
53
 
54
+ const DEFAULT_PLUGINS = [
55
+ '@syntesseraai/opencode-feature-factory@latest',
56
+ '@nick-vi/opencode-type-inject@latest',
57
+ '@franlol/opencode-md-table-formatter@latest',
58
+ '@spoons-and-mirrors/subtask2@latest',
59
+ 'opencode-pty@latest',
60
+ '@angdrew/opencode-hashline-plugin@latest',
61
+ ];
62
+
63
+ function getPackageBaseName(plugin) {
64
+ if (plugin.startsWith('@')) {
65
+ const slashIndex = plugin.indexOf('/');
66
+ if (slashIndex !== -1) {
67
+ const versionAt = plugin.indexOf('@', slashIndex + 1);
68
+ if (versionAt !== -1) {
69
+ return plugin.substring(0, versionAt);
70
+ }
71
+ }
72
+ return plugin;
73
+ }
74
+
75
+ const atIndex = plugin.indexOf('@');
76
+ if (atIndex !== -1) {
77
+ return plugin.substring(0, atIndex);
78
+ }
79
+
80
+ return plugin;
81
+ }
82
+
83
+ function hasPlugin(existing, plugin) {
84
+ const baseName = getPackageBaseName(plugin);
85
+ return existing.some((entry) => getPackageBaseName(entry) === baseName);
86
+ }
87
+
54
88
  async function ensureDir(dir) {
55
89
  try {
56
90
  await fs.mkdir(dir, { recursive: true });
@@ -127,11 +161,30 @@ async function updateMCPConfig() {
127
161
  try {
128
162
  // Read existing config if it exists
129
163
  let existingConfig = {};
164
+ let hasExistingConfigFile = false;
130
165
  try {
131
166
  const configContent = await fs.readFile(GLOBAL_CONFIG_FILE, 'utf8');
167
+ hasExistingConfigFile = true;
132
168
  existingConfig = JSON.parse(configContent);
133
- } catch {
134
- // No existing config, will create new
169
+ if (!existingConfig || typeof existingConfig !== 'object' || Array.isArray(existingConfig)) {
170
+ if (isInteractive) {
171
+ console.warn('\n⚠️ Existing opencode.json is not a JSON object. Skipping update.');
172
+ }
173
+ return;
174
+ }
175
+ } catch (error) {
176
+ if (error && error.code === 'ENOENT') {
177
+ // No existing config, will create new
178
+ } else if (error instanceof SyntaxError) {
179
+ if (isInteractive) {
180
+ console.warn(
181
+ '\n⚠️ Existing opencode.json is invalid JSON. Skipping update to avoid overwriting user config.'
182
+ );
183
+ }
184
+ return;
185
+ } else {
186
+ throw error;
187
+ }
135
188
  }
136
189
 
137
190
  // Check which MCP servers need to be added
@@ -157,15 +210,33 @@ async function updateMCPConfig() {
157
210
  }
158
211
  }
159
212
 
160
- if (serversAdded === 0) {
213
+ const existingPlugins = Array.isArray(existingConfig.plugin)
214
+ ? existingConfig.plugin.filter((entry) => typeof entry === 'string')
215
+ : [];
216
+ const pluginsToAdd = [];
217
+
218
+ for (const plugin of DEFAULT_PLUGINS) {
219
+ if (hasPlugin(existingPlugins, plugin)) {
220
+ if (isInteractive) {
221
+ console.log(` ⏭️ ${plugin}: already exists, skipping`);
222
+ }
223
+ } else {
224
+ pluginsToAdd.push(plugin);
225
+ if (isInteractive) {
226
+ console.log(` ✅ ${plugin}: will be added`);
227
+ }
228
+ }
229
+ }
230
+
231
+ if (serversAdded === 0 && pluginsToAdd.length === 0) {
161
232
  if (isInteractive) {
162
- console.log('\n✅ All MCP servers already configured. No changes needed.');
233
+ console.log('\n✅ Required MCP servers and plugins already configured. No changes needed.');
163
234
  }
164
235
  return;
165
236
  }
166
237
 
167
238
  // Backup existing config if it exists
168
- if (existingConfig && Object.keys(existingConfig).length > 0) {
239
+ if (hasExistingConfigFile && existingConfig && Object.keys(existingConfig).length > 0) {
169
240
  const timestamp = new Date().toISOString().split('T')[0].replace(/-/g, '');
170
241
  const backupFile = `${GLOBAL_CONFIG_FILE}.backup.${timestamp}`;
171
242
  await fs.writeFile(backupFile, JSON.stringify(existingConfig, null, 2));
@@ -181,6 +252,7 @@ async function updateMCPConfig() {
181
252
  ...existingMcp,
182
253
  ...serversToAdd,
183
254
  },
255
+ plugin: [...existingPlugins, ...pluginsToAdd],
184
256
  };
185
257
 
186
258
  // Write updated config
@@ -188,11 +260,13 @@ async function updateMCPConfig() {
188
260
  await fs.writeFile(GLOBAL_CONFIG_FILE, JSON.stringify(updatedConfig, null, 2));
189
261
 
190
262
  if (isInteractive) {
191
- console.log(`\n✅ Added ${serversAdded} MCP server(s) to: ${GLOBAL_CONFIG_FILE}`);
263
+ console.log(`\n✅ Updated OpenCode config: ${GLOBAL_CONFIG_FILE}`);
264
+ console.log(` Added ${serversAdded} MCP server(s)`);
265
+ console.log(` Added ${pluginsToAdd.length} plugin(s)`);
192
266
  if (serversSkipped > 0) {
193
267
  console.log(` Skipped ${serversSkipped} existing server(s)`);
194
268
  }
195
- console.log('\n📝 Note: Restart OpenCode to load new MCP configuration');
269
+ console.log('\n📝 Note: Restart OpenCode to load new configuration');
196
270
  }
197
271
  } catch (error) {
198
272
  if (isInteractive) {
@@ -1,8 +1,4 @@
1
- import { readJsonFile } from './quality-gate-config.js';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- const GLOBAL_OPENCODE_DIR = join(homedir(), '.config', 'opencode');
5
- const GLOBAL_OPENCODE_CONFIG_PATH = join(GLOBAL_OPENCODE_DIR, 'opencode.json');
1
+ import { isRecord, updateGlobalOpenCodeConfigBlock } from './opencode-global-config.js';
6
2
  /**
7
3
  * Built-in opencode agents that should be disabled when the Feature Factory
8
4
  * plugin is active. These are the standard opencode agents that overlap with
@@ -42,50 +38,20 @@ export function mergeAgentConfigs(existing, defaults) {
42
38
  * @param $ - Bun shell instance
43
39
  */
44
40
  export async function updateAgentConfig($) {
45
- // Read existing global config
46
- const globalJson = await readJsonFile($, GLOBAL_OPENCODE_CONFIG_PATH);
47
- // Get existing agent configs from global config
48
- const existingAgentConfigs = (globalJson?.agent ?? {});
49
- // Merge with default agent configs
50
- const updatedAgentConfigs = mergeAgentConfigs(existingAgentConfigs, DEFAULT_DISABLED_AGENTS);
51
- // Check if any changes are needed
52
- const hasChanges = Object.keys(DEFAULT_DISABLED_AGENTS).some((agentName) => !existingAgentConfigs[agentName]);
53
- if (!hasChanges) {
54
- // All default agent configs already exist, no need to update
55
- return;
56
- }
57
- // Prepare updated global config
58
- const updatedGlobalConfig = {
59
- ...(globalJson ?? {}),
60
- agent: updatedAgentConfigs,
61
- };
62
- // Ensure global config directory exists
63
- try {
64
- await $ `mkdir -p ${GLOBAL_OPENCODE_DIR}`.quiet();
65
- }
66
- catch {
67
- // Directory might already exist, ignore
68
- }
69
- // Backup existing global config if it exists and has content
70
- if (globalJson && Object.keys(globalJson).length > 0) {
71
- try {
72
- const timestamp = new Date().toISOString().split('T')[0].replace(/-/g, '');
73
- const backupPath = `${GLOBAL_OPENCODE_CONFIG_PATH}.backup.${timestamp}`;
74
- const backupContent = JSON.stringify(globalJson, null, 2);
75
- await $ `echo ${backupContent} > ${backupPath}`.quiet();
76
- }
77
- catch (error) {
78
- // Backup failed, but continue anyway
79
- console.warn('[feature-factory] Could not create backup:', error);
80
- }
81
- }
82
- // Write updated config to global opencode.json
83
- const configContent = JSON.stringify(updatedGlobalConfig, null, 2);
84
- try {
85
- await $ `echo ${configContent} > ${GLOBAL_OPENCODE_CONFIG_PATH}`.quiet();
86
- }
87
- catch (error) {
88
- // Silently fail - don't block if we can't write the config
89
- console.warn('[feature-factory] Could not update agent config:', error);
90
- }
41
+ void $;
42
+ await updateGlobalOpenCodeConfigBlock({
43
+ blockName: 'agent',
44
+ warningLabel: 'agent config',
45
+ update: (existingBlock) => {
46
+ const existingAgentConfigs = isRecord(existingBlock)
47
+ ? existingBlock
48
+ : undefined;
49
+ const updatedAgentConfigs = mergeAgentConfigs(existingAgentConfigs, DEFAULT_DISABLED_AGENTS);
50
+ const hasChanges = Object.keys(DEFAULT_DISABLED_AGENTS).some((agentName) => !existingAgentConfigs?.[agentName]);
51
+ return {
52
+ nextBlock: updatedAgentConfigs,
53
+ changed: hasChanges,
54
+ };
55
+ },
56
+ });
91
57
  }
@@ -1,8 +1,4 @@
1
- import { readJsonFile } from './quality-gate-config.js';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- const GLOBAL_OPENCODE_DIR = join(homedir(), '.config', 'opencode');
5
- const GLOBAL_OPENCODE_CONFIG_PATH = join(GLOBAL_OPENCODE_DIR, 'opencode.json');
1
+ import { isRecord, updateGlobalOpenCodeConfigBlock } from './opencode-global-config.js';
6
2
  /**
7
3
  * Default MCP server configuration to be added by the plugin
8
4
  * These servers will be merged into the global OpenCode config.
@@ -54,50 +50,20 @@ export function mergeMCPServers(existing, defaults) {
54
50
  * @param $ - Bun shell instance
55
51
  */
56
52
  export async function updateMCPConfig($) {
57
- // Read existing global config
58
- const globalJson = await readJsonFile($, GLOBAL_OPENCODE_CONFIG_PATH);
59
- // Get existing MCP servers from global config
60
- const existingMcpServers = (globalJson?.mcp ?? {});
61
- // Merge with default MCP servers
62
- const updatedMcpServers = mergeMCPServers(existingMcpServers, DEFAULT_MCP_SERVERS);
63
- // Check if any changes are needed
64
- const hasChanges = Object.keys(DEFAULT_MCP_SERVERS).some((serverName) => !existingMcpServers[serverName]);
65
- if (!hasChanges) {
66
- // All default servers already exist, no need to update
67
- return;
68
- }
69
- // Prepare updated global config
70
- const updatedGlobalConfig = {
71
- ...(globalJson ?? {}),
72
- mcp: updatedMcpServers,
73
- };
74
- // Ensure global config directory exists
75
- try {
76
- await $ `mkdir -p ${GLOBAL_OPENCODE_DIR}`.quiet();
77
- }
78
- catch {
79
- // Directory might already exist, ignore
80
- }
81
- // Backup existing global config if it exists and has content
82
- if (globalJson && Object.keys(globalJson).length > 0) {
83
- try {
84
- const timestamp = new Date().toISOString().split('T')[0].replace(/-/g, '');
85
- const backupPath = `${GLOBAL_OPENCODE_CONFIG_PATH}.backup.${timestamp}`;
86
- const backupContent = JSON.stringify(globalJson, null, 2);
87
- await $ `echo ${backupContent} > ${backupPath}`.quiet();
88
- }
89
- catch (error) {
90
- // Backup failed, but continue anyway
91
- console.warn('[feature-factory] Could not create backup:', error);
92
- }
93
- }
94
- // Write updated config to global opencode.json
95
- const configContent = JSON.stringify(updatedGlobalConfig, null, 2);
96
- try {
97
- await $ `echo ${configContent} > ${GLOBAL_OPENCODE_CONFIG_PATH}`.quiet();
98
- }
99
- catch (error) {
100
- // Silently fail - don't block if we can't write the config
101
- console.warn('[feature-factory] Could not update MCP config:', error);
102
- }
53
+ void $;
54
+ await updateGlobalOpenCodeConfigBlock({
55
+ blockName: 'mcp',
56
+ warningLabel: 'MCP config',
57
+ update: (existingBlock) => {
58
+ const existingMcpServers = isRecord(existingBlock)
59
+ ? existingBlock
60
+ : undefined;
61
+ const updatedMcpServers = mergeMCPServers(existingMcpServers, DEFAULT_MCP_SERVERS);
62
+ const hasChanges = Object.keys(DEFAULT_MCP_SERVERS).some((serverName) => !existingMcpServers?.[serverName]);
63
+ return {
64
+ nextBlock: updatedMcpServers,
65
+ changed: hasChanges,
66
+ };
67
+ },
68
+ });
103
69
  }
@@ -0,0 +1,9 @@
1
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
2
+ export declare function updateGlobalOpenCodeConfigBlock<T>(options: {
3
+ blockName: string;
4
+ warningLabel: string;
5
+ update: (existingBlock: unknown) => {
6
+ nextBlock: T;
7
+ changed: boolean;
8
+ };
9
+ }): Promise<void>;
@@ -0,0 +1,79 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const GLOBAL_OPENCODE_DIR = join(homedir(), '.config', 'opencode');
5
+ const GLOBAL_OPENCODE_CONFIG_PATH = join(GLOBAL_OPENCODE_DIR, 'opencode.json');
6
+ export function isRecord(value) {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+ async function loadGlobalOpenCodeConfig() {
10
+ try {
11
+ const configContent = await readFile(GLOBAL_OPENCODE_CONFIG_PATH, 'utf8');
12
+ const parsed = JSON.parse(configContent);
13
+ if (!isRecord(parsed)) {
14
+ return {
15
+ config: {},
16
+ hasExistingFile: true,
17
+ parseError: true,
18
+ };
19
+ }
20
+ return {
21
+ config: parsed,
22
+ hasExistingFile: true,
23
+ parseError: false,
24
+ };
25
+ }
26
+ catch (error) {
27
+ if (typeof error === 'object' &&
28
+ error !== null &&
29
+ 'code' in error &&
30
+ error.code === 'ENOENT') {
31
+ return {
32
+ config: {},
33
+ hasExistingFile: false,
34
+ parseError: false,
35
+ };
36
+ }
37
+ if (error instanceof SyntaxError) {
38
+ return {
39
+ config: {},
40
+ hasExistingFile: true,
41
+ parseError: true,
42
+ };
43
+ }
44
+ throw error;
45
+ }
46
+ }
47
+ export async function updateGlobalOpenCodeConfigBlock(options) {
48
+ const { blockName, warningLabel, update } = options;
49
+ const loaded = await loadGlobalOpenCodeConfig();
50
+ if (loaded.parseError) {
51
+ console.warn(`[feature-factory] Could not update ${warningLabel}: existing opencode.json is not valid JSON`);
52
+ return;
53
+ }
54
+ const { nextBlock, changed } = update(loaded.config[blockName]);
55
+ if (!changed) {
56
+ return;
57
+ }
58
+ const updatedConfig = {
59
+ ...loaded.config,
60
+ [blockName]: nextBlock,
61
+ };
62
+ await mkdir(GLOBAL_OPENCODE_DIR, { recursive: true });
63
+ if (loaded.hasExistingFile && Object.keys(loaded.config).length > 0) {
64
+ try {
65
+ const timestamp = new Date().toISOString().split('T')[0].replace(/-/g, '');
66
+ const backupPath = `${GLOBAL_OPENCODE_CONFIG_PATH}.backup.${timestamp}`;
67
+ await writeFile(backupPath, JSON.stringify(loaded.config, null, 2));
68
+ }
69
+ catch (error) {
70
+ console.warn('[feature-factory] Could not create backup:', error);
71
+ }
72
+ }
73
+ try {
74
+ await writeFile(GLOBAL_OPENCODE_CONFIG_PATH, JSON.stringify(updatedConfig, null, 2));
75
+ }
76
+ catch (error) {
77
+ console.warn(`[feature-factory] Could not update ${warningLabel}:`, error);
78
+ }
79
+ }
@@ -1,8 +1,4 @@
1
- import { readJsonFile } from './quality-gate-config.js';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- const GLOBAL_OPENCODE_DIR = join(homedir(), '.config', 'opencode');
5
- const GLOBAL_OPENCODE_CONFIG_PATH = join(GLOBAL_OPENCODE_DIR, 'opencode.json');
1
+ import { updateGlobalOpenCodeConfigBlock } from './opencode-global-config.js';
6
2
  /**
7
3
  * Default plugins that should be present in the global OpenCode config.
8
4
  * These will be merged into the existing plugin array without removing
@@ -88,53 +84,20 @@ export function mergePlugins(existing, defaults) {
88
84
  * @param $ - Bun shell instance
89
85
  */
90
86
  export async function updatePluginConfig($) {
91
- // Read existing global config
92
- const globalJson = await readJsonFile($, GLOBAL_OPENCODE_CONFIG_PATH);
93
- // Get existing plugins from global config
94
- const existingPlugins = Array.isArray(globalJson?.plugin)
95
- ? globalJson.plugin
96
- : undefined;
97
- // Check if any changes are needed (by base name)
98
- const existingArray = existingPlugins ?? [];
99
- const hasChanges = DEFAULT_PLUGINS.some((plugin) => !hasPlugin(existingArray, plugin));
100
- if (!hasChanges) {
101
- // All default plugins already present (possibly with pinned versions)
102
- return;
103
- }
104
- // Merge with default plugins
105
- const updatedPlugins = mergePlugins(existingPlugins, DEFAULT_PLUGINS);
106
- // Prepare updated global config
107
- const updatedGlobalConfig = {
108
- ...(globalJson ?? {}),
109
- plugin: updatedPlugins,
110
- };
111
- // Ensure global config directory exists
112
- try {
113
- await $ `mkdir -p ${GLOBAL_OPENCODE_DIR}`.quiet();
114
- }
115
- catch {
116
- // Directory might already exist, ignore
117
- }
118
- // Backup existing global config if it exists and has content
119
- if (globalJson && Object.keys(globalJson).length > 0) {
120
- try {
121
- const timestamp = new Date().toISOString().split('T')[0].replace(/-/g, '');
122
- const backupPath = `${GLOBAL_OPENCODE_CONFIG_PATH}.backup.${timestamp}`;
123
- const backupContent = JSON.stringify(globalJson, null, 2);
124
- await $ `echo ${backupContent} > ${backupPath}`.quiet();
125
- }
126
- catch (error) {
127
- // Backup failed, but continue anyway
128
- console.warn('[feature-factory] Could not create backup:', error);
129
- }
130
- }
131
- // Write updated config to global opencode.json
132
- const configContent = JSON.stringify(updatedGlobalConfig, null, 2);
133
- try {
134
- await $ `echo ${configContent} > ${GLOBAL_OPENCODE_CONFIG_PATH}`.quiet();
135
- }
136
- catch (error) {
137
- // Silently fail - don't block if we can't write the config
138
- console.warn('[feature-factory] Could not update plugin config:', error);
139
- }
87
+ void $;
88
+ await updateGlobalOpenCodeConfigBlock({
89
+ blockName: 'plugin',
90
+ warningLabel: 'plugin config',
91
+ update: (existingBlock) => {
92
+ const existingPlugins = Array.isArray(existingBlock)
93
+ ? existingBlock.filter((entry) => typeof entry === 'string')
94
+ : undefined;
95
+ const existingArray = existingPlugins ?? [];
96
+ const hasChanges = DEFAULT_PLUGINS.some((plugin) => !hasPlugin(existingArray, plugin));
97
+ return {
98
+ nextBlock: mergePlugins(existingPlugins, DEFAULT_PLUGINS),
99
+ changed: hasChanges,
100
+ };
101
+ },
102
+ });
140
103
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@syntesseraai/opencode-feature-factory",
4
- "version": "0.6.7",
4
+ "version": "0.6.8",
5
5
  "type": "module",
6
6
  "description": "OpenCode plugin for Feature Factory agents - provides sub-agents and skills for validation, review, security, and architecture assessment",
7
7
  "license": "MIT",