cli

Settings Service Architecture

Architecture documentation for the global settings service implementation.

Overview

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.

Architecture Diagram

┌─────────────────────────────────────────────────────────────────┐
│                      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'  │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Design Decisions

1. TypeSpec-First Domain Model

Decision: Define Settings in TypeSpec, generate TypeScript types.

Rationale:

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:

2. Singleton Pattern (Application-Level)

Decision: Global singleton service for settings access.

Rationale:

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:

3. Singleton Constraint (Database-Level)

Decision: Enforce single Settings record in database via SQLite constraint.

Rationale:

Implementation:

CREATE TABLE IF NOT EXISTS settings (
  id TEXT PRIMARY KEY CHECK (id = 'singleton'),
  -- other columns...
);

Trade-offs:

4. Repository Pattern with Prepared Statements

Decision: SQLite repository using prepared statements with named parameters.

Rationale:

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:

5. Database Mapping Layer

Decision: Separate mapper functions for TypeScript ↔ SQLite conversion.

Rationale:

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:

6. Dependency Injection with tsyringe

Decision: Use tsyringe IoC container for dependency management.

Rationale:

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:

7. Async Bootstrap Pattern

Decision: CLI uses async bootstrap function before parsing commands.

Rationale:

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:

Data Flow

Initialization Flow (First Run)

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

Subsequent Runs

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

Update Flow

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

Agent Configuration Flow

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.type field is the single source of truth for which agent executor runs. All code paths that need an IAgentExecutor MUST go through IAgentExecutorProvider.getExecutor() — never call the factory directly or hardcode the agent type. See AGENTS.md — Settings-Driven Agent Resolution.

File Structure

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)

Testing Strategy

Unit Tests (Use Cases)

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();
  });
});

Integration Tests (Repository)

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);
  });
});

E2E Tests (CLI)

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);
  });
});

Performance Considerations

Database Location

Singleton Access

Migration Performance

Security Considerations

SQL Injection Prevention

Prepared statements with named parameters prevent SQL injection

File Permissions

Sensitive Data

Future Enhancements

Planned Features

  1. Settings validation - JSON Schema validation at runtime
  2. Settings migration - Version settings schema for backwards compatibility
  3. Encrypted fields - Support for encrypted sensitive values
  4. Settings export/import - Backup and restore settings
  5. Multi-profile support - Different settings per project/context

Possible Migrations

From SQLite to PostgreSQL:

From singleton to context-aware:


Maintaining This Document

Update when:

Related files: