162 lines
5.5 KiB
Python
Executable File
162 lines
5.5 KiB
Python
Executable File
"""
|
|
AI-LSC — Manifest support.
|
|
|
|
Reads and writes ``.ai-lsc-project.json`` and ``.ai-lsc-jobs.json``
|
|
files that provide project-level context for the chat interface.
|
|
Pure filesystem + JSON work — no UI.
|
|
|
|
Manifest schema (``.ai-lsc-project.json``)::
|
|
|
|
{
|
|
"project": "my-project",
|
|
"description": "Brief description for AI context",
|
|
"language": "python",
|
|
"entry_point": "src/main.py",
|
|
"architecture": "System architecture notes",
|
|
"environment_notes": "Runtime environment details",
|
|
"dependencies": ["package1", "package2"],
|
|
"context_files": ["src/**/*.py", "README.md"],
|
|
"exclude": ["__pycache__", "*.pyc", ".git"]
|
|
}
|
|
|
|
JCL schema (``.ai-lsc-jobs.json``)::
|
|
|
|
{
|
|
"jobs": [
|
|
{"name": "...", "command": "...", "cwd": "..."},
|
|
...
|
|
]
|
|
}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import glob as glob_mod
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from ai_lsc.constants import MANIFEST_FILE_NAME, JCL_FILE_NAME
|
|
|
|
# Maximum directory traversal depth when searching for manifests.
|
|
_MAX_WALK_DEPTH: int = 20
|
|
|
|
|
|
class ManifestSupport:
|
|
"""Static utility class for manifest and JCL file operations."""
|
|
|
|
@staticmethod
|
|
def discover_manifest(directory: str | Path) -> Path | None:
|
|
"""Walk up from *directory* to find the nearest manifest file.
|
|
|
|
Stops after ``_MAX_WALK_DEPTH`` iterations or when the
|
|
filesystem root is reached.
|
|
"""
|
|
current = Path(directory).resolve()
|
|
for _ in range(_MAX_WALK_DEPTH):
|
|
candidate = current / MANIFEST_FILE_NAME
|
|
if candidate.exists():
|
|
return candidate
|
|
parent = current.parent
|
|
if parent == current:
|
|
return None
|
|
current = parent
|
|
return None
|
|
|
|
@staticmethod
|
|
def load_manifest(path: str | Path) -> dict[str, Any]:
|
|
"""Load and return the manifest dict, or ``{}`` on failure."""
|
|
p = Path(path)
|
|
if not p.exists():
|
|
return {}
|
|
try:
|
|
return json.loads(p.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
return {}
|
|
|
|
@staticmethod
|
|
def build_system_context(manifest: dict[str, Any]) -> str:
|
|
"""Build a flat system-prompt text block from manifest data.
|
|
|
|
Source-of-truth boundary: this method renders a *derived* view of
|
|
the manifest dict. The manifest file itself is an optional
|
|
convenience; if absent, callers fall back to defaults — the
|
|
registry layer files in :mod:`ai_lsc.registry.layers` remain
|
|
the authoritative source for tool definitions.
|
|
"""
|
|
# (manifest_key, label) pairs in display order. Each entry whose
|
|
# manifest value is truthy becomes one line in the prompt.
|
|
_FIELDS: tuple[tuple[str, str], ...] = (
|
|
("description", "Description"),
|
|
("language", "Language"),
|
|
("entry_point", "Entry Point"),
|
|
("architecture", "Architecture"),
|
|
("environment_notes", "Environment"),
|
|
)
|
|
project = manifest.get("project", "Unknown Project")
|
|
dependencies = manifest.get("dependencies", [])
|
|
|
|
parts = [f"Project: {project}"]
|
|
parts.extend(
|
|
f"{label}: {manifest.get(key)}"
|
|
for key, label in _FIELDS
|
|
if manifest.get(key)
|
|
)
|
|
if dependencies:
|
|
parts.append(f"Dependencies: {', '.join(dependencies)}")
|
|
|
|
return "\n".join(parts)
|
|
|
|
@staticmethod
|
|
def resolve_context_files(
|
|
manifest: dict[str, Any],
|
|
base_dir: str | Path,
|
|
) -> list[str]:
|
|
"""Resolve glob patterns in the manifest to real file paths."""
|
|
base = Path(base_dir)
|
|
patterns = manifest.get("context_files", [])
|
|
exclude = set(manifest.get("exclude", []))
|
|
|
|
# Single-pass comprehension: expand every pattern, keep only
|
|
# regular files whose path does not contain any excluded token.
|
|
return [
|
|
f
|
|
for pattern in patterns
|
|
for f in glob_mod.glob(str(base / pattern), recursive=True)
|
|
if Path(f).is_file() and not any(ex in f for ex in exclude)
|
|
]
|
|
|
|
@staticmethod
|
|
def load_jcl(path: str | Path) -> list[dict[str, Any]]:
|
|
"""Load job entries from a JCL file, or ``[]`` on failure."""
|
|
p = Path(path)
|
|
if not p.exists():
|
|
return []
|
|
try:
|
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
return data.get("jobs", [])
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
return []
|
|
|
|
@staticmethod
|
|
def create_manifest_template(path: str | Path) -> Path:
|
|
"""Write a starter manifest template to *path*.
|
|
|
|
Returns the path of the created file.
|
|
"""
|
|
p = Path(path)
|
|
template = {
|
|
"project": "my-project",
|
|
"description": "Brief project description for AI context",
|
|
"language": "python",
|
|
"entry_point": "src/main.py",
|
|
"architecture": "Describe the system architecture",
|
|
"environment_notes": "Runtime environment details",
|
|
"dependencies": ["package1", "package2"],
|
|
"context_files": ["src/**/*.py", "README.md"],
|
|
"exclude": ["__pycache__", "*.pyc", ".git", "node_modules"],
|
|
}
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(json.dumps(template, indent=4), encoding="utf-8")
|
|
return p
|