Architecture documentation for the global settings service implementation.
The Settings Service provides global application configuration accessible throughout the CLI. It implements a singleton pattern with SQLite persistence, using Clean Architecture principles and dependency injection.
┌─────────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ CLI Entry Point (src/presentation/cli/index.ts) │ │
│ │ │ │
│ │ async function bootstrap() { │ │
│ │ 1. initializeContainer() → DI setup + migrations │ │
│ │ 2. container.resolve(InitializeSettingsUseCase) │ │
│ │ 3. initializeSettings(settings) → Singleton │ │
│ │ 4. program.parse() → Command execution │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ↓ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ InitializeSettingsUseCase │ │
│ │ execute() → Settings │ │
│ │ - Load existing settings OR │ │
│ │ - Create defaults + persist │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Port: ISettingsRepository │ │
│ │ initialize(settings): Promise<void> │ │
│ │ load(): Promise<Settings | null> │ │
│ │ update(settings): Promise<void> │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Infrastructure Layer │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ SQLiteSettingsRepository │ │
│ │ @injectable() (tsyringe DI) │ │
│ │ constructor(db: Database.Database) │ │
│ │ - Uses prepared statements (SQL injection safe) │ │
│ │ - Delegates to SettingsMapper for DB ↔ TS conversion │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ SettingsMapper (settings.mapper.ts) │ │
│ │ toDatabase(settings): SettingsRow │ │
│ │ fromDatabase(row): Settings │ │
│ │ - Flattens nested objects (models.default → model_default) │
│ │ - Converts types (boolean → integer, Date → ISO string) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ SQLite Database (~/.shep/data) │ │
│ │ Table: settings (singleton constraint on 'id') │ │
│ │ Columns: snake_case (model_default, sys_log_level, ...) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
↑
┌─────────────────────────────────────────────────────────────────┐
│ Singleton Service │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ settings.service.ts │ │
│ │ let settingsInstance: Settings | null = null; │ │
│ │ │ │
│ │ initializeSettings(settings): void │ │
│ │ getSettings(): Settings │ │
│ │ hasSettings(): boolean │ │
│ │ resetSettings(): void (testing only) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Usage: │
│ import { getSettings } from '@/infrastructure/services/settings.service'; │
│ const settings = getSettings(); │
│ console.log(settings.models.default); // 'claude-sonnet-4-6' │
│ │
└─────────────────────────────────────────────────────────────────┘
Decision: Define Settings in TypeSpec, generate TypeScript types.
Rationale:
- Single source of truth for domain model
- Automatic OpenAPI spec generation for future API
- Type safety enforced at compile time
- Easy to evolve (change
.tsp→ regenerate types)
Implementation:
// Generated: packages/core/src/domain/generated/output.ts
export type Settings = BaseEntity & {
models: ModelConfiguration;
user: UserProfile;
environment: EnvironmentConfig;
system: SystemConfig;
agent: AgentConfig;
notifications: NotificationPreferences;
workflow: WorkflowConfig;
featureFlags?: FeatureFlags;
onboardingComplete: boolean;
};
export type ModelConfiguration = {
default: string; // Default model identifier for all agents
};Trade-offs:
- ✅ Type safety across application
- ✅ API documentation auto-generated
- ✅ Easy schema evolution
- ❌ Requires TypeSpec compilation step
- ❌ Generated code must be committed
Decision: Global singleton service for settings access.
Rationale:
- Settings are global by nature (apply to entire CLI session)
- Avoid passing settings through every function call
- Initialization happens once at CLI bootstrap
- Predictable lifecycle (init → use → exit)
Implementation:
// packages/core/src/infrastructure/services/settings.service.ts
let settingsInstance: Settings | null = null;
export function initializeSettings(settings: Settings): void {
if (settingsInstance !== null) {
throw new Error('Settings already initialized.');
}
settingsInstance = settings;
}
export function getSettings(): Settings {
if (settingsInstance === null) {
throw new Error('Settings not initialized.');
}
return settingsInstance;
}Trade-offs:
- ✅ Simple, efficient access pattern
- ✅ Clear initialization point (bootstrap)
- ✅ Fail-fast if accessed before initialization
- ❌ Global state (harder to test in parallel)
- ✅ Test helper
resetSettings()mitigates testing issue
Decision: Enforce single Settings record in database via SQLite constraint.
Rationale:
- Prevent accidental duplicate settings
- Match singleton semantics at persistence layer
- Database validates business rule (only one Settings allowed)
Implementation:
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY CHECK (id = 'singleton'),
-- other columns...
);Trade-offs:
- ✅ Data integrity enforced at DB level
- ✅ Impossible to create duplicate settings
- ✅ Clear error if constraint violated
- ❌ Slightly more complex queries (always WHERE id = 'singleton')
Decision: SQLite repository using prepared statements with named parameters.
Rationale:
- SQL injection prevention (security requirement)
- Clear separation between domain model and persistence
- Testable with in-memory databases
- Easy to swap SQLite for PostgreSQL/MySQL later
Implementation:
// packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts
@injectable()
export class SQLiteSettingsRepository implements ISettingsRepository {
constructor(private readonly db: Database.Database) {}
async initialize(settings: Settings): Promise<void> {
const row = toDatabase(settings);
const stmt = this.db.prepare(`
INSERT INTO settings (
id, created_at, updated_at,
model_default, agent_type, agent_auth_method, ...
) VALUES (
@id, @created_at, @updated_at,
@model_default, @agent_type, @agent_auth_method, ...
)
`);
stmt.run(row); // Named parameters prevent SQL injection
}
}Trade-offs:
- ✅ SQL injection impossible (prepared statements)
- ✅ Testable with in-memory DB
- ✅ Clean Architecture (domain → port → implementation)
- ❌ More boilerplate than ORMs
- ✅ Full control over SQL (performance optimization)
Decision: Separate mapper functions for TypeScript ↔ SQLite conversion.
Rationale:
- SQLite uses snake_case, TypeScript uses camelCase
- SQLite has limited types (no boolean, only integer 0/1)
- Nested objects must be flattened (models.default -> model_default)
- Separation of concerns (repository logic vs. mapping logic)
Implementation:
// packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts
export interface SettingsRow {
id: string;
created_at: string; // ISO 8601 string (SQLite TEXT)
updated_at: string;
model_default: string; // Flattened from models.default
sys_auto_update: number; // Boolean -> Integer (SQLite limitation)
agent_type: string;
// ... many more flattened columns
}
export function toDatabase(settings: Settings): SettingsRow {
return {
id: settings.id,
created_at: settings.createdAt.toISOString(),
model_default: settings.models.default,
sys_auto_update: settings.system.autoUpdate ? 1 : 0,
agent_type: settings.agent.type,
// ...
};
}
export function fromDatabase(row: SettingsRow): Settings {
return {
id: row.id,
createdAt: new Date(row.created_at),
models: {
default: row.model_default,
},
system: {
autoUpdate: row.sys_auto_update === 1,
// ...
},
agent: {
type: row.agent_type as AgentType,
// ...
},
// ...
};
}Trade-offs:
- ✅ Clear bidirectional conversion
- ✅ Easy to test mapping independently
- ✅ Repository stays focused on persistence logic
- ❌ Manual field mapping (no auto-mapping like ORMs)
- ✅ Explicit is better than implicit (catch bugs early)
Decision: Use tsyringe IoC container for dependency management.
Rationale:
- Clean Architecture requires dependency inversion
- Repository implementations resolved at runtime
- Use cases don't depend on concrete implementations
- Easy to mock dependencies in tests
Implementation:
// packages/core/src/infrastructure/di/container.ts
import 'reflect-metadata';
import { container } from 'tsyringe';
export async function initializeContainer(): Promise<typeof container> {
const db = await getSQLiteConnection();
await runSQLiteMigrations(db);
// Register database instance
container.registerInstance<Database.Database>('Database', db);
// Register repository implementations
container.register<ISettingsRepository>('ISettingsRepository', {
useFactory: (c) => {
const database = c.resolve<Database.Database>('Database');
return new SQLiteSettingsRepository(database);
},
});
// Register use cases as singletons
container.registerSingleton(InitializeSettingsUseCase);
container.registerSingleton(LoadSettingsUseCase);
container.registerSingleton(UpdateSettingsUseCase);
return container;
}
// src/presentation/cli/index.ts
import 'reflect-metadata'; // MUST be first import
async function bootstrap() {
await initializeContainer();
const useCase = container.resolve(InitializeSettingsUseCase);
const settings = await useCase.execute();
initializeSettings(settings);
}Trade-offs:
- ✅ Loose coupling (depend on interfaces, not implementations)
- ✅ Testability (inject mocks easily)
- ✅ Single responsibility (container manages lifecycle)
- ❌ Requires
reflect-metadataimport (boilerplate) - ❌ Decorator syntax (
@injectable()) required
Decision: CLI uses async bootstrap function before parsing commands.
Rationale:
- Database connection is async
- Migrations must run before repository usage
- Settings must be loaded before commands execute
- Fail-fast: If initialization fails, exit before CLI starts
Implementation:
// src/presentation/cli/index.ts
async function bootstrap() {
try {
// Step 1: Initialize DI container (database + migrations)
await initializeContainer();
// Step 2: Initialize settings (load or create defaults)
const initializeSettingsUseCase = container.resolve(InitializeSettingsUseCase);
const settings = await initializeSettingsUseCase.execute();
initializeSettings(settings);
// Step 3: Set up Commander CLI and parse arguments
const program = new Command().name('shep').version(version);
program.parse();
} catch (error) {
messages.error('Failed to initialize CLI', error);
process.exit(1);
}
}
bootstrap();Trade-offs:
- ✅ Clear initialization order
- ✅ Async operations handled correctly
- ✅ Fail-fast on errors (exit code 1)
- ✅ Global error handlers catch uncaught exceptions
- ❌ CLI startup slightly slower (database connection)
- ✅ Startup time is negligible (~50ms) for better reliability
1. User runs: shep version
↓
2. bootstrap() → initializeContainer()
↓
3. getSQLiteConnection() → ~/.shep/data
↓
4. runSQLiteMigrations() → CREATE TABLE settings
↓
5. container.resolve(InitializeSettingsUseCase)
↓
6. useCase.execute()
├─ repository.load() → null (no settings exist)
├─ Create defaults: { models, user, environment, system }
└─ repository.initialize(defaults)
↓
7. initializeSettings(defaults) → Singleton instance
↓
8. program.parse() → Execute 'version' command
↓
9. Command can call getSettings() → Access singleton
1. User runs: shep <command>
↓
2. bootstrap() → initializeContainer()
↓
3. getSQLiteConnection() → ~/.shep/data (already exists)
↓
4. runSQLiteMigrations() → Check user_version (no changes)
↓
5. container.resolve(InitializeSettingsUseCase)
↓
6. useCase.execute()
├─ repository.load() → Settings (existing record)
└─ Return loaded settings (no database write)
↓
7. initializeSettings(settings) → Singleton instance
↓
8. program.parse() → Execute command
↓
9. Command calls getSettings() → Access singleton
1. User runs: shep settings update --model claude-opus-4-5
|
2. Command handler:
+-- settings = getSettings() (load from singleton)
+-- settings.models.default = 'claude-opus-4-5'
+-- container.resolve(UpdateSettingsUseCase)
↓
3. useCase.execute(settings)
├─ repository.update(settings) → SQL UPDATE
└─ Return updated settings
↓
4. Singleton instance is already updated (by reference)
↓
5. CLI continues with new settings for remaining commands
1. User runs: shep settings agent --agent cursor
↓
2. ConfigureAgentUseCase:
├─ AgentValidatorService.isAvailable('cursor') → checks `agent --version`
├─ Load current settings
├─ Update settings.agent.type = 'cursor'
└─ repository.update(settings) → SQL UPDATE
↓
3. Singleton reset + reinitialize (agent.command.ts)
↓
4. Any subsequent command that needs an executor:
├─ Inject IAgentExecutorProvider from DI container
└─ provider.getExecutor()
→ internally reads getSettings().agent.type → 'cursor'
→ delegates to AgentExecutorFactory.createExecutor('cursor', settings.agent)
→ CursorExecutorService
ARCHITECTURAL RULE: The
settings.agent.typefield is the single source of truth for which agent executor runs. All code paths that need anIAgentExecutorMUST go throughIAgentExecutorProvider.getExecutor()— never call the factory directly or hardcode the agent type. See AGENTS.md — Settings-Driven Agent Resolution.
packages/core/src/
├── domain/
│ └── generated/
│ └── output.ts # TypeSpec-generated types (Settings interface)
│
├── application/
│ ├── ports/
│ │ └── output/
│ └── settings.repository.interface.ts # ISettingsRepository port
│ └── use-cases/
│ └── settings/
│ ├── initialize-settings.use-case.ts # Load or create defaults
│ ├── load-settings.use-case.ts # Load existing
│ └── update-settings.use-case.ts # Update existing
│
└── infrastructure/
├── di/
│ └── container.ts # tsyringe DI container setup
├── persistence/
│ └── sqlite/
│ ├── connection.ts # Database connection (~/.shep/data)
│ ├── migrations.ts # Schema migrations (user_version)
│ └── mappers/
│ └── settings.mapper.ts # TS ↔ SQL conversion
├── repositories/
│ └── sqlite-settings.repository.ts # SQLiteSettingsRepository impl
└── services/
└── settings.service.ts # Singleton service (getSettings, initializeSettings)
src/presentation/
└── cli/
└── index.ts # CLI entry point (bootstrap function)
Mock the repository interface:
// tests/unit/application/use-cases/settings/initialize-settings.test.ts
describe('InitializeSettingsUseCase', () => {
it('should load existing settings', async () => {
const mockRepo = {
load: vi.fn().mockResolvedValue(existingSettings),
initialize: vi.fn(),
};
const useCase = new InitializeSettingsUseCase(mockRepo);
const result = await useCase.execute();
expect(result).toBe(existingSettings);
expect(mockRepo.initialize).not.toHaveBeenCalled();
});
});Use in-memory SQLite:
// tests/integration/infrastructure/repositories/sqlite-settings.repository.test.ts
import 'reflect-metadata'; // IMPORTANT: Required for tsyringe
describe('SQLiteSettingsRepository', () => {
let db: Database.Database;
let repository: SQLiteSettingsRepository;
beforeEach(async () => {
db = createInMemoryDatabase(); // :memory:
await runSQLiteMigrations(db);
repository = new SQLiteSettingsRepository(db);
});
afterEach(() => db.close());
it('should persist settings', async () => {
await repository.initialize(settings);
const loaded = await repository.load();
expect(loaded).toMatchObject(settings);
});
});Use temporary directory:
// tests/e2e/cli/settings-initialization.test.ts
describe('CLI: settings initialization', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'shep-cli-test-'));
});
it('should create ~/.shep/ directory on first run', () => {
const runner = createCliRunner({ env: { HOME: tempDir } });
const result = runner.run('version');
expect(result.success).toBe(true);
expect(existsSync(join(tempDir, '.shep'))).toBe(true);
});
});- Location:
~/.shep/data(user home directory) - Connection: Opened once at bootstrap, reused for all operations
- Size: Minimal (~16KB for empty database, ~20KB with settings)
- Pattern: Direct memory access (no function call overhead)
- Latency: ~1μs (microsecond) per getSettings() call
- No serialization: Returns direct object reference
- First run: ~20ms (create directory + database + table + insert)
- Subsequent runs: ~5ms (open connection + check user_version)
- Impact: Negligible for CLI usage
✅ Prepared statements with named parameters prevent SQL injection
- Directory:
~/.shep/created with0700(owner-only access) - Database: Inherits directory permissions (not world-readable)
- User email: Stored in plaintext (not sensitive in local DB)
- No passwords: Settings never contain credentials
- API keys: Stored separately (not in Settings model)
- Settings validation - JSON Schema validation at runtime
- Settings migration - Version settings schema for backwards compatibility
- Encrypted fields - Support for encrypted sensitive values
- Settings export/import - Backup and restore settings
- Multi-profile support - Different settings per project/context
From SQLite to PostgreSQL:
- Change repository implementation only
- Port interface stays the same
- Use cases unchanged
- Mapper layer handles SQL dialect differences
From singleton to context-aware:
- Add
contextparameter to getSettings(context) - Support per-project settings overrides
- Global settings as fallback
- CLAUDE.md - DI container documentation
- CLAUDE.md - Data storage locations
- tdd-guide.md - Testing strategy
- typespec-guide.md - TypeSpec domain modeling
- repository-pattern.md - Repository pattern details
- clean-architecture.md - Architecture principles
Update when:
- Settings schema changes (TypeSpec model updated)
- New use cases added (load/update/delete)
- Repository implementation changes (e.g., PostgreSQL)
- Singleton pattern changes (e.g., context-aware)
Related files:
tsp/domain/entities/settings.tsp- TypeSpec model definitionpackages/core/src/infrastructure/di/container.ts- DI container setuppackages/core/src/infrastructure/services/settings.service.ts- Singleton servicepackages/core/src/infrastructure/repositories/sqlite-settings.repository.ts- Repository implementation