Skip to content
Open
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
43 changes: 19 additions & 24 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,33 +82,28 @@ agent-fox init [OPTIONS]

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `--config` | flag | off | Create a local `.agent-fox/config.toml` (overwrites if present) |
| `--skills` | flag | off | Install bundled Claude Code skills into `.claude/skills/` |
| `--profiles` | flag | off | Copy default archetype profiles into `.agent-fox/profiles/` |

Creates the `.agent-fox/` directory structure with a default configuration file,
sets up the integration branch (configured via `workspace.integration_branch`, default: `main`), updates `.gitignore`, creates
`.claude/settings.local.json` with canonical permissions, scaffolds an
`AGENTS.md` template with project instructions for coding agents, and creates
`.agent-fox/steering.md` as a placeholder for project-level agent directives. If
`AGENTS.md` already exists it is silently skipped to preserve customizations.
If `.agent-fox/steering.md` already exists it is also silently skipped.

**Fresh init:** Generates `config.toml` programmatically from the Pydantic
configuration models. Every available option appears as a commented-out entry
with its description, valid range (if constrained), and default value.

**Re-init (config merge):** When `config.toml` already exists, `init` merges
it with the current schema non-destructively:

- **Preserves** all active (uncommented) user values.
- **Adds** new schema fields as commented-out entries with descriptions and
defaults.
- **Marks deprecated** any active fields not recognized by the current schema
with a `# DEPRECATED` prefix.
- **Preserves** user comments and formatting.
- **No-op** if the config is already up to date (byte-for-byte identical).
- If the existing file contains invalid TOML, a warning is logged and the
file is left untouched.
Creates the `.agent-fox/` directory structure, sets up the integration branch
(configured via `workspace.integration_branch`, default: `main`), updates
`.gitignore`, creates `.claude/settings.local.json` with canonical permissions,
scaffolds an `AGENTS.md` template with project instructions for coding agents,
and creates `.agent-fox/steering.md` as a placeholder for project-level agent
directives. If `AGENTS.md` already exists it is silently skipped to preserve
customizations. If `.agent-fox/steering.md` already exists it is also silently
skipped.

**Local config (`--config`):** A local `.agent-fox/config.toml` is only created
when `--config` is explicitly passed. When present, the local config is the
**sole** config source — the global `~/.agent-fox/config.toml` is ignored
entirely. Without a local config, the global config applies. If a local
`config.toml` already exists, `--config` overwrites it with a fresh template.

**Config loading precedence:**
- **No local config** → global config at `~/.agent-fox/config.toml` applies
- **Local config present** → only local config applies (global ignored)

**Steering document:** `init` creates `.agent-fox/steering.md` as an empty
placeholder on first run. This file is the user's persistent directive surface
Expand Down
80 changes: 28 additions & 52 deletions packages/af/af/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,7 @@ def _ensure_global_config_for_init() -> str | None:
"""Create the global config at ``$HOME/.agent-fox/config.toml`` if absent.

Returns a user-facing message string, or ``None`` when HOME is
unresolvable. Never overwrites an existing global config (even when
``--force`` is passed to ``af init``).
unresolvable. Never overwrites an existing global config.

Requirements: 13-REQ-8.1, 13-REQ-8.2, 13-REQ-8.E1
"""
Expand Down Expand Up @@ -122,41 +121,31 @@ def _ensure_global_config_for_init() -> str | None:
return f"Created global config at {global_config}"


def _ensure_local_config_for_init(project_root: Path, *, force: bool) -> str:
"""Create or overwrite the local config template at ``.agent-fox/config.toml``.
def _create_local_config(project_root: Path) -> str:
"""Create or overwrite the local config at ``.agent-fox/config.toml``.

The local config is always an all-comments template produced by
:func:`generate_local_config_template`.

Requirements: 13-REQ-8.3, 13-REQ-8.4, 13-REQ-8.5
Called only when ``--config`` is passed to ``af init``. Always
writes the promoted-fields template, overwriting any existing file.
"""
from agentfox.core.config_gen import generate_local_config_template

local_dir = project_root / ".agent-fox"
config_path = local_dir / "config.toml"

if config_path.exists() and not force:
# 13-REQ-8.4: leave existing local config unmodified
return "Skipped existing local config (use --force to regenerate)"

# Ensure directory exists
local_dir.mkdir(parents=True, exist_ok=True)

# 13-REQ-8.3, 13-REQ-8.5: write all-comments template
config_path.write_text(generate_local_config_template(), encoding="utf-8")

if force:
return f"Regenerated local config at {config_path}"
return f"Created local config at {config_path}"


@exit_codes(**{"0": "Success", "1": "Error"})
@click.command("init")
@click.option(
"--force",
"--config",
"create_config",
is_flag=True,
default=False,
help="Force overwrite of the local config template.",
help="Create a local .agent-fox/config.toml (overwrites if present).",
)
@click.option(
"--skills",
Expand All @@ -171,75 +160,62 @@ def _ensure_local_config_for_init(project_root: Path, *, force: bool) -> str:
help="Copy default archetype profiles into .agent-fox/profiles/.",
)
@click.pass_context
def init_cmd(ctx: click.Context, force: bool, skills: bool, profiles: bool) -> None:
def init_cmd(ctx: click.Context, create_config: bool, skills: bool, profiles: bool) -> None:
"""Initialize the current project for agent-fox.

Creates the .agent-fox/ directory structure with a default
configuration file, sets up the integration branch, and
updates .gitignore.
Creates the .agent-fox/ directory structure, sets up the integration
branch, and updates .gitignore. Pass --config to also create a
local config.toml for per-project overrides.
"""
# 04-REQ-2.1, 04-REQ-2.6: retrieve OutputManager from context
om = get_output_manager(ctx)
json_mode = om.json_mode

project_root = Path.cwd()

# --- Config scaffolding (13-REQ-8.*) ---
# Global and local config creation happens before the git check
# so config files are always created even outside a git repository.
# --- Global config scaffolding ---
global_msg = _ensure_global_config_for_init()
local_msg = _ensure_local_config_for_init(project_root, force=force)

# --- Local config (opt-in via --config) ---
local_msg: str | None = None
if create_config:
local_msg = _create_local_config(project_root)

# --- Git-dependent initialization ---
# 01-REQ-3.5: check we are in a git repository for the rest of init
if not _is_git_repo():
if json_mode:
om.emit(
{
"status": "ok",
"global_config": global_msg,
"local_config": local_msg,
}
)
data: dict = {"status": "ok", "global_config": global_msg}
if local_msg:
data["local_config"] = local_msg
om.emit(data)
return
if global_msg:
click.echo(global_msg)
click.echo(local_msg)
if local_msg:
click.echo(local_msg)
return

config_path = project_root / ".agent-fox" / "config.toml"
# Save local config content before init_project may modify it
local_content_before = config_path.read_text(encoding="utf-8") if config_path.exists() else None

result = init_project(project_root, skills=skills, quiet=json_mode)

# Restore local config content — init_project's merge_existing_config
# may have modified the all-comments template or existing config.
# Spec 13 requires: new -> all-comments template; existing+no-force ->
# original content; existing+force -> all-comments template.
if local_content_before is not None:
config_path.write_text(local_content_before, encoding="utf-8")

# 23-REQ-4.1, 04-REQ-2.6: JSON output via OutputManager
if json_mode:
result_data: dict = {
"status": "ok",
"agents_md": result.agents_md,
"steering_md": result.steering_md,
"global_config": global_msg,
"local_config": local_msg,
}
if local_msg:
result_data["local_config"] = local_msg
if result.skills_installed:
result_data["skills_installed"] = result.skills_installed
if result.labels_ensured:
result_data["labels_ensured"] = result.labels_ensured
om.emit(result_data)
return

# Text output — config messages
if global_msg:
click.echo(global_msg)
click.echo(local_msg)
if local_msg:
click.echo(local_msg)

if result.agents_md == "created":
click.echo("Created AGENTS.md.")
Expand Down
85 changes: 29 additions & 56 deletions packages/agentfox/agentfox/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -834,26 +834,42 @@ def _load_config_single_file(path: Path) -> AgentFoxConfig:


def _load_config_global_local() -> AgentFoxConfig:
"""Load config using the global+local merge scheme.
"""Load config from local or global source.

1. Check for AF_CONFIG deprecation.
2. Resolve $HOME and load/auto-create the global config.
3. Load the local config from CWD/.agent-fox/config.toml.
4. Merge using shallow section replacement.
5. Validate through AgentFoxConfig.
If a local config (``.agent-fox/config.toml``) exists in CWD, it is
used as the **sole** config source — the global config is not read.

Requirements: 13-REQ-1.2, 13-REQ-2.1, 13-REQ-2.2, 13-REQ-2.3,
13-REQ-3.1, 13-REQ-3.2, 13-REQ-5.1, 13-REQ-5.2,
13-REQ-7.1, 13-REQ-7.2, 13-REQ-7.3, 13-REQ-7.4
Otherwise, the global config (``~/.agent-fox/config.toml``) is loaded,
auto-created if absent.
"""
# --- Global config ---
# --- Check for local config first ---
local_path = Path.cwd() / ".agent-fox" / "config.toml"

if local_path.exists():
_check_symlink(local_path)
local_dict = _parse_toml_file(local_path)

logger.debug(
"Local config found at %s — using as sole config source (global ignored)",
local_path,
)

config = _validate_config_dict(local_dict, source=str(local_path))

if "spec_tool" in local_dict:
config._spec_tool_explicit = True

return config

# --- No local config — fall through to global ---
logger.debug("No local config found at %s", local_path)

global_dict: dict = {}
home: Path | None = None

try:
home = Path.home()
except (RuntimeError, OSError):
# 13-REQ-2.3, 13-REQ-7.4: HOME unresolvable
logger.debug("$HOME could not be resolved; global config loading skipped")

if home is not None:
Expand All @@ -863,19 +879,15 @@ def _load_config_global_local() -> AgentFoxConfig:
try:
config_exists = global_config_path.exists()
except OSError as exc:
# 13-REQ-2.E2: can't even stat the path — directory is
# inaccessible, so we can't create or read the global config.
raise ConfigError(
f"Failed to create directory {global_dir}: {exc}",
path=str(global_dir),
) from exc

if not config_exists:
# 13-REQ-2.1: auto-create global config
try:
os.makedirs(str(global_dir), mode=0o700, exist_ok=True)
except OSError as exc:
# 13-REQ-2.E2: directory creation failure
raise ConfigError(
f"Failed to create directory {global_dir}: {exc}",
path=str(global_dir),
Expand All @@ -885,52 +897,13 @@ def _load_config_global_local() -> AgentFoxConfig:

global_config_path.write_text(generate_default_config(), encoding="utf-8")

# 13-REQ-2.E1: symlink rejection on final file
_check_symlink(global_config_path)

# 13-REQ-4.1: malformed global TOML -> fail fast
global_dict = _parse_toml_file(global_config_path)

# 13-REQ-7.1: debug log after successful load
logger.debug("Loaded global config from %s", global_config_path)

# --- Local config ---
local_path = Path.cwd() / ".agent-fox" / "config.toml"

if local_path.exists():
# 13-REQ-3.E1: symlink rejection on final file
_check_symlink(local_path)

# 13-REQ-4.2: malformed local TOML -> ConfigError
local_dict = _parse_toml_file(local_path)

# Track which local keys are sections (dicts) for debug logging
overridden_sections = [k for k, v in local_dict.items() if isinstance(v, dict)]

# 13-REQ-3.1, 13-REQ-3.3: shallow section replacement merge
merged_dict = shallow_merge(global_dict, local_dict)

# 13-REQ-7.2: debug log with overridden section names
logger.debug(
"Merging local config from %s (sections overridden: %s)",
local_path,
overridden_sections,
)
else:
merged_dict = global_dict
# 13-REQ-7.3: debug log when no local config found
logger.debug(
"No local config found at %s",
local_path,
)

# 13-REQ-1.3: validate and apply defaults via Pydantic
config = _validate_config_dict(merged_dict, source="merged config")
config = _validate_config_dict(global_dict, source="global config")

# 13-REQ-6.3: track whether [spec_tool] was explicitly present in the
# raw merged dict (before Pydantic filled in defaults). Used by
# agentspec to decide whether to fall back to ~/.af/settings.yaml.
if "spec_tool" in merged_dict:
if "spec_tool" in global_dict:
config._spec_tool_explicit = True

return config
Expand Down
36 changes: 21 additions & 15 deletions packages/agentfox/agentfox/core/config_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,33 +445,39 @@ def generate_default_config() -> str:


def generate_local_config_template() -> str:
"""Generate a local config template with all values commented out.
"""Generate a local config template using the promoted-fields style.

Unlike :func:`generate_default_config` (which renders only
``_VISIBLE_SECTIONS`` with some promoted values active), this function
renders **every** section and field from :class:`AgentFoxConfig` as
commented TOML. The result is a per-repo ``.agent-fox/config.toml``
template that shows all available options without activating any of them.
Uses the same layout as :func:`generate_default_config` — only
``_VISIBLE_SECTIONS`` are rendered and promoted fields are active —
but with a header indicating this is a local override file.

When the comment prefixes (``#`` or ``##``) are removed, the output is
valid TOML that can be parsed with ``tomllib``.

Requirements: 13-REQ-8.3, 13-REQ-8.5
When a local config exists it is the **sole** config source; the
global ``~/.agent-fox/config.toml`` is ignored entirely.
"""
logger.debug("Generating local config template from AgentFoxConfig")
schema = extract_schema(AgentFoxConfig)

lines: list[str] = [
"## agent-fox local configuration",
f"## Generated by agent-fox {__version__}",
f"## {_GITHUB_REPO_URL}",
"##",
"## Override global settings per-repository by uncommenting",
"## the values you want to change. Sections present here",
"## replace the corresponding global section wholesale.",
_FOOTER_COMMENT,
"## When this file exists, the global ~/.agent-fox/config.toml",
"## is ignored entirely. Uncomment and edit values to customize.",
]

for section in schema:
visible_schema = [s for s in schema if s.path in _VISIBLE_SECTIONS]
active_sections = [s for s in visible_schema if _section_has_promoted(s)]
inactive_sections = [s for s in visible_schema if not _section_has_promoted(s)]

for section in active_sections:
lines.append("")
lines.extend(_render_section_comments(section))
_render_section(section, lines)

for section in inactive_sections:
lines.append("")
_render_section(section, lines)

result = "\n".join(lines)
if not result.endswith("\n"):
Expand Down
Loading