diff --git a/CHANGELOG.md b/CHANGELOG.md
index a53bd86..9ba5790 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.20.4] - 2026-03-05
+
+Overview: Added allow-read-config command to gsd-oc-tools.cjs for managing external_directory permissions. Introduced automated GSD config folder access configuration with comprehensive test coverage and workflow integration.
+
+### Added
+
+- allow-read-config.cjs command for adding external_directory permission to read GSD config folder in `gsd-opencode/get-shit-done/bin/gsd-oc-commands/allow-read-config.cjs`
+- Comprehensive test suite for allow-read-config command with 5 tests covering permission creation, idempotency, dry-run mode, backup creation, and verbose output in `gsd-opencode/get-shit-done/bin/test/allow-read-config.test.cjs`
+- GSD config read permission step in oc-set-profile.md workflow to ensure access to `~/.config/opencode/get-shit-done/` in `gsd-opencode/get-shit-done/workflows/oc-set-profile.md`
+
+### Changed
+
+- Updated gsd-oc-tools.cjs help text and command routing to include allow-read-config command in `gsd-opencode/get-shit-done/bin/gsd-oc-tools.cjs`
+- Updated opencode.json with external_directory permission for `/Users/roki/.config/opencode/get-shit-done/**` in `opencode.json`
+
## [1.20.3] - 2026-03-03
Overview: Major CLI tools release introducing gsd-oc-tools.cjs with comprehensive profile management, validation commands, and atomic transaction support. Added separate oc_config.json for profile configuration, pre-flight model validation, and vitest testing infrastructure.
diff --git a/gsd-opencode/get-shit-done/bin/gsd-oc-commands/allow-read-config.cjs b/gsd-opencode/get-shit-done/bin/gsd-oc-commands/allow-read-config.cjs
new file mode 100644
index 0000000..45c4bd5
--- /dev/null
+++ b/gsd-opencode/get-shit-done/bin/gsd-oc-commands/allow-read-config.cjs
@@ -0,0 +1,235 @@
+/**
+ * allow-read-config.cjs — Add external_directory permission to read GSD config folder
+ *
+ * Creates or updates local opencode.json with permission to access:
+ * ~/.config/opencode/get-shit-done/
+ *
+ * This allows gsd-opencode commands to read workflow files, templates, and
+ * configuration from the global GSD installation directory.
+ *
+ * Usage:
+ * node allow-read-config.cjs # Add read permission
+ * node allow-read-config.cjs --dry-run # Preview changes
+ * node allow-read-config.cjs --verbose # Verbose output
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { output, error, createBackup } = require('../gsd-oc-lib/oc-core.cjs');
+
+/**
+ * Error codes for allow-read-config operations
+ */
+const ERROR_CODES = {
+ WRITE_FAILED: 'WRITE_FAILED',
+ APPLY_FAILED: 'APPLY_FAILED',
+ ROLLBACK_FAILED: 'ROLLBACK_FAILED',
+ INVALID_ARGS: 'INVALID_ARGS'
+};
+
+/**
+ * Get the GSD config directory path
+ * Uses environment variable if set, otherwise defaults to ~/.config/opencode/get-shit-done
+ *
+ * @returns {string} GSD config directory path
+ */
+function getGsdConfigDir() {
+ const envDir = process.env.OPENCODE_CONFIG_DIR;
+ if (envDir) {
+ return envDir;
+ }
+
+ const homeDir = os.homedir();
+ return path.join(homeDir, '.config', 'opencode', 'get-shit-done');
+}
+
+/**
+ * Build the external_directory permission pattern
+ *
+ * @param {string} gsdDir - GSD config directory
+ * @returns {string} Permission pattern with wildcard
+ */
+function buildPermissionPattern(gsdDir) {
+ // Use ** for recursive matching (all subdirectories and files)
+ return `${gsdDir}/**`;
+}
+
+/**
+ * Check if permission already exists in opencode.json
+ *
+ * @param {Object} opencodeData - Parsed opencode.json content
+ * @param {string} pattern - Permission pattern to check
+ * @returns {boolean} True if permission exists
+ */
+function permissionExists(opencodeData, pattern) {
+ const permissions = opencodeData.permission;
+
+ if (!permissions) {
+ return false;
+ }
+
+ const externalDirPerms = permissions.external_directory;
+ if (!externalDirPerms || typeof externalDirPerms !== 'object') {
+ return false;
+ }
+
+ // Check if the pattern exists and is set to "allow"
+ return externalDirPerms[pattern] === 'allow';
+}
+
+/**
+ * Main command function
+ *
+ * @param {string} cwd - Current working directory
+ * @param {string[]} args - Command line arguments
+ */
+function allowReadConfig(cwd, args) {
+ const verbose = args.includes('--verbose');
+ const dryRun = args.includes('--dry-run');
+ const raw = args.includes('--raw');
+
+ const log = verbose ? (...args) => console.error('[allow-read-config]', ...args) : () => {};
+
+ const opencodePath = path.join(cwd, 'opencode.json');
+ const backupsDir = path.join(cwd, '.planning', 'backups');
+ const gsdConfigDir = getGsdConfigDir();
+ const permissionPattern = buildPermissionPattern(gsdConfigDir);
+
+ log('Starting allow-read-config command');
+ log(`GSD config directory: ${gsdConfigDir}`);
+ log(`Permission pattern: ${permissionPattern}`);
+
+ // Check for invalid arguments
+ const validFlags = ['--verbose', '--dry-run', '--raw'];
+ const invalidArgs = args.filter(arg =>
+ arg.startsWith('--') && !validFlags.includes(arg)
+ );
+
+ if (invalidArgs.length > 0) {
+ error(`Unknown arguments: ${invalidArgs.join(', ')}`, 'INVALID_ARGS');
+ }
+
+ // Load or create opencode.json
+ let opencodeData;
+ let fileExisted = false;
+
+ if (fs.existsSync(opencodePath)) {
+ try {
+ const content = fs.readFileSync(opencodePath, 'utf8');
+ opencodeData = JSON.parse(content);
+ fileExisted = true;
+ log('Loaded existing opencode.json');
+ } catch (err) {
+ error(`Failed to parse opencode.json: ${err.message}`, 'INVALID_JSON');
+ }
+ } else {
+ // Create initial opencode.json structure
+ opencodeData = {
+ "$schema": "https://opencode.ai/config.json"
+ };
+ log('Creating new opencode.json');
+ }
+
+ // Check if permission already exists
+ const exists = permissionExists(opencodeData, permissionPattern);
+
+ if (exists) {
+ log('Permission already exists');
+ output({
+ success: true,
+ data: {
+ dryRun: dryRun,
+ action: 'permission_exists',
+ pattern: permissionPattern,
+ message: 'Permission already configured'
+ }
+ });
+ process.exit(0);
+ }
+
+ // Dry-run mode - preview changes
+ if (dryRun) {
+ log('Dry-run mode - no changes will be made');
+
+ const changes = [];
+ if (!fileExisted) {
+ changes.push('Create opencode.json');
+ }
+ changes.push(`Add external_directory permission: ${permissionPattern}`);
+
+ output({
+ success: true,
+ data: {
+ dryRun: true,
+ action: 'add_permission',
+ pattern: permissionPattern,
+ gsdConfigDir: gsdConfigDir,
+ changes: changes,
+ message: fileExisted ? 'Would update opencode.json' : 'Would create opencode.json'
+ }
+ });
+ process.exit(0);
+ }
+
+ // Create backup if file exists
+ let backupPath = null;
+ if (fileExisted) {
+ // Ensure backup directory exists
+ if (!fs.existsSync(backupsDir)) {
+ fs.mkdirSync(backupsDir, { recursive: true });
+ }
+
+ backupPath = createBackup(opencodePath, backupsDir);
+ log(`Backup created: ${backupPath}`);
+ }
+
+ // Initialize permission structure if needed
+ if (!opencodeData.permission) {
+ opencodeData.permission = {};
+ }
+
+ if (!opencodeData.permission.external_directory) {
+ opencodeData.permission.external_directory = {};
+ }
+
+ // Add the permission
+ opencodeData.permission.external_directory[permissionPattern] = 'allow';
+
+ log('Permission added to opencode.json');
+
+ // Write updated opencode.json
+ try {
+ fs.writeFileSync(opencodePath, JSON.stringify(opencodeData, null, 2) + '\n', 'utf8');
+ log('Updated opencode.json');
+ } catch (err) {
+ // Rollback if backup exists
+ if (backupPath) {
+ try {
+ fs.copyFileSync(backupPath, opencodePath);
+ } catch (rollbackErr) {
+ error(
+ `Failed to write opencode.json AND failed to rollback: ${rollbackErr.message}`,
+ 'ROLLBACK_FAILED'
+ );
+ }
+ }
+ error(`Failed to write opencode.json: ${err.message}`, 'WRITE_FAILED');
+ }
+
+ output({
+ success: true,
+ data: {
+ action: 'add_permission',
+ pattern: permissionPattern,
+ gsdConfigDir: gsdConfigDir,
+ opencodePath: opencodePath,
+ backup: backupPath,
+ created: !fileExisted,
+ message: fileExisted ? 'opencode.json updated' : 'opencode.json created'
+ }
+ });
+ process.exit(0);
+}
+
+module.exports = allowReadConfig;
diff --git a/gsd-opencode/get-shit-done/bin/gsd-oc-tools.cjs b/gsd-opencode/get-shit-done/bin/gsd-oc-tools.cjs
index 0afbceb..8c45724 100755
--- a/gsd-opencode/get-shit-done/bin/gsd-oc-tools.cjs
+++ b/gsd-opencode/get-shit-done/bin/gsd-oc-tools.cjs
@@ -16,6 +16,7 @@
* validate-models Validate model IDs against opencode catalog
* set-profile Switch profile with interactive model selection
* get-profile Get current profile or specific profile from oc_config.json
+ * allow-read-config Add external_directory permission to read GSD config folder
* help Show this help message
*/
@@ -50,12 +51,13 @@ Available Commands:
validate-models Validate one or more model IDs against opencode catalog
set-profile Switch profile with interactive model selection wizard
get-profile Get current profile or specific profile from oc_config.json
+ allow-read-config Add external_directory permission to read ~/.config/opencode/get-shit-done/**
help Show this help message
Options:
--verbose Enable verbose output (stderr)
- --raw Output raw values instead of JSON envelope
- --dry-run Preview changes without applying (update-opencode-json)
+ --raw Output raw value instead of JSON envelope
+ --dry-run Preview changes without applying (update-opencode-json, allow-read-config)
Examples:
node gsd-oc-tools.cjs check-opencode-json
@@ -66,6 +68,8 @@ Examples:
node gsd-oc-tools.cjs get-profile
node gsd-oc-tools.cjs get-profile genius
node gsd-oc-tools.cjs get-profile --raw
+ node gsd-oc-tools.cjs allow-read-config
+ node gsd-oc-tools.cjs allow-read-config --dry-run
`.trim();
console.log(helpText);
@@ -121,9 +125,11 @@ switch (command) {
break;
}
-
-
-
+ case 'allow-read-config': {
+ const allowReadConfig = require('./gsd-oc-commands/allow-read-config.cjs');
+ allowReadConfig(cwd, flags);
+ break;
+ }
default:
error(`Unknown command: ${command}\nRun 'node gsd-oc-tools.cjs help' for available commands.`);
diff --git a/gsd-opencode/get-shit-done/bin/test/allow-read-config.test.cjs b/gsd-opencode/get-shit-done/bin/test/allow-read-config.test.cjs
new file mode 100644
index 0000000..76427e0
--- /dev/null
+++ b/gsd-opencode/get-shit-done/bin/test/allow-read-config.test.cjs
@@ -0,0 +1,262 @@
+/**
+ * allow-read-config.test.cjs — Tests for allow-read-config command
+ *
+ * Tests the allow-read-config command functionality:
+ * - Permission creation
+ * - Idempotency (detecting existing permission)
+ * - Dry-run mode
+ * - Backup creation
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { execSync } = require('child_process');
+
+const CLI_PATH = path.join(__dirname, '../gsd-oc-commands/allow-read-config.cjs');
+const TOOLS_PATH = path.join(__dirname, '../gsd-oc-tools.cjs');
+
+/**
+ * Create a temporary test directory
+ */
+function createTestDir() {
+ const testDir = path.join(os.tmpdir(), `gsd-oc-test-${Date.now()}`);
+ fs.mkdirSync(testDir, { recursive: true });
+ return testDir;
+}
+
+/**
+ * Clean up test directory
+ */
+function cleanupTestDir(testDir) {
+ if (fs.existsSync(testDir)) {
+ fs.rmSync(testDir, { recursive: true, force: true });
+ }
+}
+
+/**
+ * Run CLI command and parse JSON output
+ */
+function runCLI(testDir, args) {
+ const cmd = `node ${TOOLS_PATH} allow-read-config ${args.join(' ')}`;
+ const output = execSync(cmd, { cwd: testDir, encoding: 'utf8' });
+ return JSON.parse(output);
+}
+
+/**
+ * Test: Create new opencode.json with permission
+ */
+function testCreatePermission() {
+ console.log('Test: Create new opencode.json with permission...');
+
+ const testDir = createTestDir();
+
+ try {
+ const result = runCLI(testDir, []);
+
+ if (!result.success) {
+ throw new Error(`Expected success, got: ${JSON.stringify(result)}`);
+ }
+
+ if (result.data.action !== 'add_permission') {
+ throw new Error(`Expected action 'add_permission', got: ${result.data.action}`);
+ }
+
+ if (result.data.created !== true) {
+ throw new Error(`Expected created=true, got: ${result.data.created}`);
+ }
+
+ // Verify opencode.json was created
+ const opencodePath = path.join(testDir, 'opencode.json');
+ if (!fs.existsSync(opencodePath)) {
+ throw new Error('opencode.json was not created');
+ }
+
+ const content = JSON.parse(fs.readFileSync(opencodePath, 'utf8'));
+ if (!content.permission?.external_directory) {
+ throw new Error('Permission not added to opencode.json');
+ }
+
+ console.log('✓ PASS: Create permission\n');
+ return true;
+ } catch (err) {
+ console.error('✗ FAIL:', err.message, '\n');
+ return false;
+ } finally {
+ cleanupTestDir(testDir);
+ }
+}
+
+/**
+ * Test: Idempotency - detect existing permission
+ */
+function testIdempotency() {
+ console.log('Test: Idempotency (detect existing permission)...');
+
+ const testDir = createTestDir();
+
+ try {
+ // First call - create permission
+ runCLI(testDir, []);
+
+ // Second call - should detect existing
+ const result = runCLI(testDir, []);
+
+ if (!result.success) {
+ throw new Error(`Expected success, got: ${JSON.stringify(result)}`);
+ }
+
+ if (result.data.action !== 'permission_exists') {
+ throw new Error(`Expected action 'permission_exists', got: ${result.data.action}`);
+ }
+
+ console.log('✓ PASS: Idempotency\n');
+ return true;
+ } catch (err) {
+ console.error('✗ FAIL:', err.message, '\n');
+ return false;
+ } finally {
+ cleanupTestDir(testDir);
+ }
+}
+
+/**
+ * Test: Dry-run mode
+ */
+function testDryRun() {
+ console.log('Test: Dry-run mode...');
+
+ const testDir = createTestDir();
+
+ try {
+ const result = runCLI(testDir, ['--dry-run']);
+
+ if (!result.success) {
+ throw new Error(`Expected success, got: ${JSON.stringify(result)}`);
+ }
+
+ if (result.data.dryRun !== true) {
+ throw new Error(`Expected dryRun=true, got: ${result.data.dryRun}`);
+ }
+
+ // Verify opencode.json was NOT created
+ const opencodePath = path.join(testDir, 'opencode.json');
+ if (fs.existsSync(opencodePath)) {
+ throw new Error('opencode.json should not be created in dry-run mode');
+ }
+
+ console.log('✓ PASS: Dry-run mode\n');
+ return true;
+ } catch (err) {
+ console.error('✗ FAIL:', err.message, '\n');
+ return false;
+ } finally {
+ cleanupTestDir(testDir);
+ }
+}
+
+/**
+ * Test: Backup creation on update
+ */
+function testBackupCreation() {
+ console.log('Test: Backup creation on update...');
+
+ const testDir = createTestDir();
+
+ try {
+ // Create initial opencode.json
+ const opencodePath = path.join(testDir, 'opencode.json');
+ const initialContent = {
+ "$schema": "https://opencode.ai/config.json",
+ "model": "test/model"
+ };
+ fs.writeFileSync(opencodePath, JSON.stringify(initialContent, null, 2) + '\n');
+
+ // Run allow-read-config
+ const result = runCLI(testDir, []);
+
+ if (!result.success) {
+ throw new Error(`Expected success, got: ${JSON.stringify(result)}`);
+ }
+
+ if (!result.data.backup) {
+ throw new Error('Expected backup path, got none');
+ }
+
+ if (!fs.existsSync(result.data.backup)) {
+ throw new Error(`Backup file does not exist: ${result.data.backup}`);
+ }
+
+ // Verify backup content matches original
+ const backupContent = JSON.parse(fs.readFileSync(result.data.backup, 'utf8'));
+ if (JSON.stringify(backupContent) !== JSON.stringify(initialContent)) {
+ throw new Error('Backup content does not match original');
+ }
+
+ console.log('✓ PASS: Backup creation\n');
+ return true;
+ } catch (err) {
+ console.error('✗ FAIL:', err.message, '\n');
+ return false;
+ } finally {
+ cleanupTestDir(testDir);
+ }
+}
+
+/**
+ * Test: Verbose output
+ */
+function testVerbose() {
+ console.log('Test: Verbose output...');
+
+ const testDir = createTestDir();
+
+ try {
+ const cmd = `node ${TOOLS_PATH} allow-read-config --verbose`;
+ const output = execSync(cmd, { cwd: testDir, encoding: 'utf8', stdio: 'pipe' });
+
+ // Verbose output should contain log messages to stderr
+ // We just verify it doesn't crash
+ console.log('✓ PASS: Verbose output\n');
+ return true;
+ } catch (err) {
+ console.error('✗ FAIL:', err.message, '\n');
+ return false;
+ } finally {
+ cleanupTestDir(testDir);
+ }
+}
+
+/**
+ * Run all tests
+ */
+function runTests() {
+ console.log('Running allow-read-config tests...\n');
+ console.log('=' .repeat(50));
+ console.log();
+
+ const results = [
+ testCreatePermission(),
+ testIdempotency(),
+ testDryRun(),
+ testBackupCreation(),
+ testVerbose()
+ ];
+
+ const passed = results.filter(r => r).length;
+ const total = results.length;
+
+ console.log('=' .repeat(50));
+ console.log(`Results: ${passed}/${total} tests passed`);
+
+ if (passed === total) {
+ console.log('✓ All tests passed!\n');
+ process.exit(0);
+ } else {
+ console.error(`✗ ${total - passed} test(s) failed\n`);
+ process.exit(1);
+ }
+}
+
+// Run tests
+runTests();
diff --git a/gsd-opencode/get-shit-done/workflows/oc-set-profile.md b/gsd-opencode/get-shit-done/workflows/oc-set-profile.md
index 0ce9e39..d241dd4 100644
--- a/gsd-opencode/get-shit-done/workflows/oc-set-profile.md
+++ b/gsd-opencode/get-shit-done/workflows/oc-set-profile.md
@@ -5,6 +5,7 @@ You are executing the `/gsd-set-profile` command. Switch the project's active mo
This command reads/writes:
- `.planning/oc_config.json` — source of truth for profile state (profile_type, stage-to-model mapping)
- `opencode.json` — agent model assignments (derived from profile; updated automatically by CLI)
+- `opencode.json` — external_directory permissions for reading GSD config folder (added automatically)
Do NOT modify agent .md files. Profile switching only updates these two JSON files.
@@ -48,6 +49,27 @@ Active profile: **{profile_name}**
+## Step 0: Ensure GSD config read permission
+
+Before any profile operations, ensure opencode.json has permission to read the GSD config folder:
+
+```bash
+node ~/.config/opencode/get-shit-done/bin/gsd-oc-tools.cjs allow-read-config --dry-run
+```
+
+Parse the response:
+- **`success: true` with `action: "permission_exists"`** — Permission already configured. Continue to Step 1.
+- **`success: true` with `action: "add_permission"`** — Permission would be added. Execute without `--dry-run`:
+
+Attempt to switch to the saved profile:
+```bash
+node ~/.config/opencode/get-shit-done/bin/gsd-oc-tools.cjs allow-read-config
+```
+
+- **`success: false`** — Handle error appropriately.
+
+This ensures gsd-opencode can access workflow files, templates, and configuration from `~/.config/opencode/get-shit-done/`.
+
## Step 1: Load current profile
Run `get-profile` to read the current state from `.planning/oc_config.json`:
@@ -148,6 +170,8 @@ Done! Updated {profile_name} profile:
We just updated the `./opencode.json` file. Apply the agent settings you need to **restart your opencode**.
+Note: GSD config read permission has been configured to allow access to `~/.config/opencode/get-shit-done/`.
+
diff --git a/gsd-opencode/package.json b/gsd-opencode/package.json
index 5e70cc7..f4ef548 100644
--- a/gsd-opencode/package.json
+++ b/gsd-opencode/package.json
@@ -1,6 +1,6 @@
{
"name": "gsd-opencode",
- "version": "1.10.0",
+ "version": "1.20.4",
"description": "GSD-OpenCode distribution manager - install, verify, and maintain your GSD-OpenCode installation",
"type": "module",
"main": "bin/gsd.js",
diff --git a/opencode.json b/opencode.json
index 8c93d3c..442bb50 100644
--- a/opencode.json
+++ b/opencode.json
@@ -34,5 +34,10 @@
"gsd-integration-checker": {
"model": "bailian-coding-plan/MiniMax-M2.5"
}
+ },
+ "permission": {
+ "external_directory": {
+ "~/.config/opencode/get-shit-done/**": "allow"
+ }
}
}