Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 54 additions & 18 deletions cli/src/client/forge-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*
* Wraps AgentAPI, ToolAPI, DefinitionResolver, and other SDK managers
* to provide a clean interface for CLI commands.
*
* Now uses unified config (.fractary/config.yaml) with the forge: section.
*/

// Type-only imports
Expand All @@ -15,9 +17,8 @@ import type {
AgentInfo,
ToolInfo,
DefinitionRegistryConfig as RegistryConfig,
ForgeSectionConfig,
} from '@fractary/forge';
// Dynamic imports to avoid loading SDK and js-yaml at module time
import type { ForgeYamlConfig } from '../config/config-types.js';
import * as path from 'path';
import * as os from 'os';

Expand All @@ -33,14 +34,16 @@ export interface ForgeClientOptions {
* - Agent resolution and management
* - Tool resolution and execution
* - Registry operations
*
* Uses unified config (.fractary/config.yaml) forge: section.
*/
export class ForgeClient {
private resolver: DefinitionResolver;
private agentAPI: AgentAPI;
private toolAPI: ToolAPI;
private organization: string;
private projectRoot: string;
private config: ForgeYamlConfig;
private config: ForgeSectionConfig;

/**
* Private constructor - use ForgeClient.create() instead
Expand All @@ -49,7 +52,7 @@ export class ForgeClient {
resolver: DefinitionResolver,
agentAPI: AgentAPI,
toolAPI: ToolAPI,
config: ForgeYamlConfig,
config: ForgeSectionConfig,
projectRoot: string
) {
this.resolver = resolver;
Expand All @@ -62,28 +65,55 @@ export class ForgeClient {

/**
* Create ForgeClient instance
*
* Loads configuration from unified config (.fractary/config.yaml) forge: section.
* Falls back to old config location (.fractary/forge/config.yaml) for migration.
*/
static async create(options?: ForgeClientOptions): Promise<ForgeClient> {
// Dynamic imports to avoid loading SDK and js-yaml at module time
const { DefinitionResolver, AgentAPI, ToolAPI } = await import('@fractary/forge');
const { readYamlConfig } = await import('../config/migrate-config.js');
// Dynamic imports to avoid loading SDK at module time
const {
DefinitionResolver,
AgentAPI,
ToolAPI,
findProjectRoot,
loadForgeSection,
needsMigration,
safeLoadForgeSection,
} = await import('@fractary/forge');

const projectRoot = options?.projectRoot || process.cwd();
const configPath = path.join(projectRoot, '.fractary/forge/config.yaml');
// Find project root
const projectRoot = options?.projectRoot || await findProjectRoot() || process.cwd();

// Load configuration
let config: ForgeYamlConfig;
// Load configuration from unified config
let config: ForgeSectionConfig;
try {
config = await readYamlConfig(configPath);
// First try unified config
const loadedConfig = await safeLoadForgeSection(projectRoot);

if (loadedConfig) {
config = loadedConfig;
} else {
// Check if migration is needed
const migrationNeeded = await needsMigration(projectRoot);
if (migrationNeeded) {
throw new Error(
'Old configuration found at .fractary/forge/config.yaml. ' +
'Run "fractary-forge configure" to migrate to unified config.'
);
}
throw new Error(
'Forge configuration not found. Run "fractary-forge configure" to create one.'
);
}
} catch (error) {
throw new Error(
`Failed to load forge configuration. Run "fractary-forge init" to create one.\nError: ${(error as Error).message}`
`Failed to load forge configuration. Run "fractary-forge configure" to create one.\nError: ${(error as Error).message}`
);
}

// Override organization if provided
if (options?.organization) {
config.organization = options.organization;
config = { ...config, organization: options.organization };
}

// Build resolver config
Expand All @@ -105,12 +135,18 @@ export class ForgeClient {
}

/**
* Build resolver configuration from YAML config
* Build resolver configuration from forge section config
*/
private static buildResolverConfig(
config: ForgeYamlConfig,
config: ForgeSectionConfig,
projectRoot: string
): RegistryConfig {
// Resolve token from environment variable
// Note: token_env has a default value ('FRACTARY_TOKEN'), so we check
// if the environment variable itself is set, not just if token_env exists
const tokenEnvName = config.registry.stockyard.token_env;
const stockyardApiKey = tokenEnvName ? process.env[tokenEnvName] : undefined;

return {
local: {
enabled: config.registry.local.enabled,
Expand All @@ -128,7 +164,7 @@ export class ForgeClient {
stockyard: {
enabled: config.registry.stockyard.enabled,
url: config.registry.stockyard.url,
apiKey: config.registry.stockyard.api_key,
apiKey: stockyardApiKey,
},
};
}
Expand Down Expand Up @@ -228,7 +264,7 @@ export class ForgeClient {
return this.projectRoot;
}

getConfig(): ForgeYamlConfig {
getConfig(): ForgeSectionConfig {
return this.config;
}

Expand Down
195 changes: 195 additions & 0 deletions cli/src/commands/configure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* Configure Forge Command
*
* Manages forge configuration in the unified config file (.fractary/config.yaml).
*
* Options:
* --org <slug> Organization slug
* --global Also init global registry
* --force Overwrite existing config
* --dry-run Preview changes without applying
* --validate-only Validate existing config
*/

import { Command } from 'commander';
import chalk from 'chalk';
import * as yaml from 'js-yaml';
import {
getOrgFromGitRemote,
getOrgFromProjectPath,
isValidOrgSlug,
normalizeOrgSlug,
} from '../utils/git-utils.js';

export function configureCommand(): Command {
const cmd = new Command('configure');

cmd
.description('Configure Forge in the unified config file (.fractary/config.yaml)')
.option('--org <slug>', 'Organization slug (e.g., "fractary")')
.option('--global', 'Also initialize global registry (~/.fractary/registry)')
.option('--force', 'Overwrite existing configuration')
.option('--dry-run', 'Preview changes without applying')
.option('--validate-only', 'Validate existing configuration')
.action(async (options) => {
try {
// Dynamic import to avoid loading SDK at module time
const {
findProjectRoot,
forgeConfigExists,
needsMigration,
migrateOldForgeConfig,
initializeForgeConfig,
validateForgeConfiguration,
previewForgeConfig,
loadForgeSection,
} = await import('@fractary/forge');

// Handle --validate-only
if (options.validateOnly) {
console.log(chalk.blue('Validating Forge configuration...\n'));

const projectRoot = await findProjectRoot();
if (!projectRoot) {
console.log(chalk.red('Error: Not in a Fractary project (no .fractary or .git directory found)'));
process.exit(1);
}

const result = await validateForgeConfiguration(projectRoot);

if (result.valid) {
console.log(chalk.green('✓'), 'Configuration is valid\n');
console.log(chalk.dim('Organization:'), chalk.cyan(result.config?.organization));
console.log(chalk.dim('Schema version:'), chalk.cyan(result.config?.schema_version));
console.log(chalk.dim('Local registry:'), result.config?.registry?.local?.enabled ? chalk.green('enabled') : chalk.yellow('disabled'));
console.log(chalk.dim('Global registry:'), result.config?.registry?.global?.enabled ? chalk.green('enabled') : chalk.yellow('disabled'));
console.log(chalk.dim('Stockyard:'), result.config?.registry?.stockyard?.enabled ? chalk.green('enabled') : chalk.yellow('disabled'));
} else {
console.log(chalk.red('✗'), 'Configuration has errors:\n');
for (const error of result.errors || []) {
console.log(chalk.red(' •'), `${error.path}: ${error.message}`);
}
process.exit(1);
}
return;
}

console.log(chalk.blue('Configuring Forge...\n'));

// Find project root
const projectRoot = await findProjectRoot() || process.cwd();

// Resolve organization
let org = options.org;

// Validate user-provided org
if (org && !isValidOrgSlug(org)) {
const normalized = normalizeOrgSlug(org);
console.log(chalk.yellow(`⚠ Organization "${org}" is not a valid slug, normalizing to: ${normalized}`));
org = normalized;
}

if (!org) {
// Try git remote first
org = getOrgFromGitRemote();
}

if (!org) {
// Fall back to project path
org = getOrgFromProjectPath(projectRoot);
console.log(chalk.yellow(`⚠ Could not detect organization, using: ${org}`));
console.log(chalk.dim(' Use --org <slug> to specify explicitly\n'));
} else {
console.log(chalk.dim(`Organization: ${chalk.cyan(org)}\n`));
}

// Handle --dry-run
if (options.dryRun) {
console.log(chalk.blue('Dry run mode - showing what would be created:\n'));

const preview = previewForgeConfig(projectRoot, org);

console.log(chalk.bold('Configuration file:'));
console.log(chalk.dim(' Path:'), preview.configPath);
console.log();

console.log(chalk.bold('Forge section:'));
console.log(chalk.dim(yaml.dump(preview.forgeConfig, { indent: 2 })));

console.log(chalk.bold('Directories to create:'));
for (const dir of preview.directories) {
console.log(chalk.dim(' •'), dir);
}

console.log(chalk.yellow('\nNo changes made (dry run).'));
return;
}

// Check for existing config
const exists = await forgeConfigExists(projectRoot);

if (exists && !options.force) {
console.log(chalk.yellow('⚠ Forge configuration already exists in .fractary/config.yaml'));
console.log(chalk.dim(' Use --force to overwrite'));
console.log(chalk.dim(' Use --validate-only to check current configuration'));
process.exit(1);
}

// Check for migration from old config
const needsMigrationCheck = await needsMigration(projectRoot);
if (needsMigrationCheck) {
console.log(chalk.blue('Found old configuration at .fractary/forge/config.yaml'));
console.log(chalk.dim('Migrating to unified config...\n'));

const migrationResult = await migrateOldForgeConfig(projectRoot);
if (migrationResult.migrated) {
console.log(chalk.green('✓'), 'Configuration migrated successfully');
console.log(chalk.dim(' Backup created at:'), migrationResult.backupPath);
console.log();
}
} else {
// Initialize new configuration
console.log(chalk.dim('Creating directory structure...'));

await initializeForgeConfig(projectRoot, org, {
force: options.force,
initGlobal: options.global,
});

console.log(chalk.green('✓'), chalk.dim('.fractary/config.yaml (forge section)'));
console.log(chalk.green('✓'), chalk.dim('.fractary/agents/'));
console.log(chalk.green('✓'), chalk.dim('.fractary/tools/'));
console.log(chalk.green('✓'), chalk.dim('.fractary/forge/'));

if (options.global) {
console.log(chalk.green('✓'), chalk.dim('~/.fractary/registry/'));
}
}

// Load and display final config
const config = await loadForgeSection(projectRoot);

// Success message
console.log(chalk.green('\n✨ Forge configured successfully!\n'));

console.log(chalk.bold('Configuration:'));
console.log(chalk.dim(' File: .fractary/config.yaml (forge section)'));
console.log(chalk.dim(' Organization:'), chalk.cyan(config.organization));
console.log(chalk.dim(' Schema version:'), chalk.cyan(config.schema_version));
console.log(chalk.dim(' Agents:'), config.registry.local.agents_path);
console.log(chalk.dim(' Tools:'), config.registry.local.tools_path);

console.log(chalk.bold('\nNext steps:'));
console.log(chalk.dim(' 1. Create an agent:'), chalk.cyan('fractary-forge agent-create <name>'));
console.log(chalk.dim(' 2. Create a tool:'), chalk.cyan('fractary-forge tool-create <name>'));
console.log(chalk.dim(' 3. List agents:'), chalk.cyan('fractary-forge agent-list'));
console.log(chalk.dim(' 4. Validate config:'), chalk.cyan('fractary-forge configure --validate-only'));

} catch (error) {
console.error(chalk.red('Error:'), (error as Error).message);
process.exit(1);
}
});

return cmd;
}
Loading