Skip to content

Commit f046eb2

Browse files
committed
Adds an --agentic-run option
1 parent 791cf38 commit f046eb2

16 files changed

Lines changed: 744 additions & 112 deletions

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ bin/release-version export-ignore
1414
bin/start-watchman export-ignore
1515
box.json.dist export-ignore
1616
CHANGELOG.md export-ignore
17+
CLAUDE.md export-ignore
1718
example/ export-ignore
1819
Formula/ export-ignore
1920
LICENSE.md export-ignore

CLAUDE.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Lean Package Validator
2+
3+
A PHP CLI tool that validates whether a project's `.gitattributes` file is correctly configured to exclude development
4+
artifacts from release archives.
5+
6+
## Tech Stack
7+
8+
- **PHP 8.1+** with `declare(strict_types=1)` on all files
9+
- **Symfony Console** for the CLI framework
10+
- **PHPUnit 11** for testing
11+
- **PHPStan level 8** for static analysis
12+
- **PHP-CS-Fixer** (PSR-2/PSR-12) for code style
13+
14+
## Commands
15+
16+
```bash
17+
# Run tests
18+
composer lpv:test
19+
20+
# Run tests with coverage
21+
composer lpv:test-with-coverage
22+
23+
# Fix code style
24+
composer lpv:cs-fix
25+
26+
# Lint code style (check only)
27+
composer lpv:cs-lint
28+
29+
# Static analysis
30+
composer lpv:static-analyse
31+
32+
# All pre-commit checks
33+
composer lpv:pre-commit-check
34+
35+
# Spell check
36+
composer lpv:spell-check
37+
38+
# Dependency analysis
39+
composer lpv:dependency-analyse
40+
```
41+
42+
## Project Structure
43+
44+
- `bin/lean-package-validator` — CLI entry point
45+
- `src/Commands/` — CLI command classes
46+
- `src/Presets/` — Language presets (PHP, Python, Go, JavaScript, Rust)
47+
- `src/` — Core classes (Analyser, Archive, Glob, Tree, etc.)
48+
- `tests/` — PHPUnit test suite mirroring `src/` structure
49+
- `resources/boost/skills/` — AI assistant skills
50+
51+
## Conventions
52+
53+
- Namespace root: `Stolt\LeanPackage\`
54+
- PSR-4 autoloading, one class per file
55+
- Full type hints on all method parameters and return types
56+
- Tests use `Stolt\LeanPackage\Tests\` namespace

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,34 @@ This project [includes](./resources/boost/skills) three AI skills focused on man
336336
- __create__: generate a `.gitattributes` file when it is missing.
337337
- __update__: reconcile an existing `.gitattributes` file with expected export-ignore rules.
338338

339+
### Agentic-friendly output
340+
341+
All commands support the `--agentic-run` option, which switches the output from human-readable text to a structured JSON object. This is useful when integrating the tool into AI workflows or automation pipelines where machine-readable output is preferred.
342+
343+
``` bash
344+
lean-package-validator validate --agentic-run [<directory>]
345+
```
346+
347+
``` json
348+
{
349+
"command": "validate",
350+
"status": "success",
351+
"message": "The .gitattributes file is considered valid.",
352+
"valid": true
353+
}
354+
```
355+
356+
Each response always includes `command`, `status` (`success` or `failure`), and `message` fields. Commands also include additional context-specific fields:
357+
358+
| Command | Additional fields on success |
359+
|------------|-------------------------------------------------------|
360+
| `validate` | `valid`, `warnings` (if any), `expected_gitattributes_content` (on failure), `archive_valid`, `unexpected_artifacts` |
361+
| `create` | `gitattributes_file_path` |
362+
| `update` | `gitattributes_file_path` |
363+
| `init` | `lpv_file_path` |
364+
| `refresh` | `lpv_file_path` |
365+
| `tree` | `package`, `tree` |
366+
339367
### Running tests
340368

341369
``` bash
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Stolt\LeanPackage\Commands\Concerns;
6+
7+
use Symfony\Component\Console\Input\InputInterface;
8+
use Symfony\Component\Console\Input\InputOption;
9+
use Symfony\Component\Console\Output\OutputInterface;
10+
11+
trait OutputOptions
12+
{
13+
protected function addAgenticOutputOption(callable $addOption): void
14+
{
15+
$addOption('agentic-run', null, InputOption::VALUE_NONE, 'Enable agentic-friendly output formatting');
16+
}
17+
18+
protected function isAgenticRun(InputInterface $input): bool
19+
{
20+
return (bool) $input->getOption('agentic-run');
21+
}
22+
23+
/**
24+
* @param array<string, mixed> $extra
25+
*/
26+
protected function writeAgenticOutput(
27+
OutputInterface $output,
28+
string $command,
29+
bool $success,
30+
string $message,
31+
array $extra = []
32+
): void {
33+
$data = \array_merge(
34+
['command' => $command, 'status' => $success ? 'success' : 'failure', 'message' => $message],
35+
$extra
36+
);
37+
$output->writeln((string) \json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
38+
}
39+
}

src/Commands/CreateCommand.php

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Stolt\LeanPackage\Analyser;
88
use Stolt\LeanPackage\Commands\Concerns\GeneratesGitattributesOptions;
9+
use Stolt\LeanPackage\Commands\Concerns\OutputOptions;
910
use Stolt\LeanPackage\GitattributesFileRepository;
1011
use Symfony\Component\Console\Command\Command;
1112
use Symfony\Component\Console\Input\InputArgument;
@@ -16,6 +17,7 @@
1617
final class CreateCommand extends Command
1718
{
1819
use GeneratesGitattributesOptions;
20+
use OutputOptions;
1921

2022
/**
2123
* @var string $defaultName
@@ -53,12 +55,16 @@ protected function configure(): void
5355
InputOption::VALUE_NONE,
5456
'Do not write any files. Output the expected .gitattributes content'
5557
));
58+
$this->addAgenticOutputOption(function (...$args) {
59+
$this->getDefinition()->addOption(new InputOption(...$args));
60+
});
5661
}
5762

5863
protected function execute(InputInterface $input, OutputInterface $output): int
5964
{
6065
$directory = (string) $input->getArgument('directory') ?: \getcwd();
6166
$this->analyser->setDirectory($directory);
67+
$isAgenticRun = $this->isAgenticRun($input);
6268

6369
// Apply options that influence generation
6470
if (!$this->applyGenerationOptions($input, $output, $this->analyser)) {
@@ -68,16 +74,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int
6874
$gitattributesPath = $this->analyser->getGitattributesFilePath();
6975

7076
if (\file_exists($gitattributesPath) && $input->getOption('dry-run') !== true) {
71-
$output->writeln('A .gitattributes file already exists. Use the update command to modify it.');
72-
77+
$message = 'A .gitattributes file already exists. Use the update command to modify it.';
78+
if ($isAgenticRun) {
79+
$this->writeAgenticOutput($output, 'create', false, $message);
80+
} else {
81+
$output->writeln($message);
82+
}
7383
return self::FAILURE;
7484
}
7585

7686
$expected = $this->analyser->getExpectedGitattributesContent();
7787

7888
if ($expected === '') {
79-
$output->writeln('Unable to determine expected .gitattributes content for the given directory.');
80-
89+
$message = 'Unable to determine expected .gitattributes content for the given directory.';
90+
if ($isAgenticRun) {
91+
$this->writeAgenticOutput($output, 'create', false, $message);
92+
} else {
93+
$output->writeln($message);
94+
}
8195
return self::FAILURE;
8296
}
8397

@@ -91,14 +105,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int
91105
try {
92106
$this->repository->createGitattributesFile($expected);
93107
} catch (\Throwable $e) {
94-
$output->writeln('Creation of .gitattributes file failed.');
95-
108+
$message = 'Creation of .gitattributes file failed.';
109+
if ($isAgenticRun) {
110+
$this->writeAgenticOutput($output, 'create', false, $message);
111+
} else {
112+
$output->writeln($message);
113+
}
96114
return self::FAILURE;
97115
}
98116

99117
$directory = \realpath($directory);
100-
$output->writeln("A .gitattributes file has been created in {$directory}.");
101-
118+
$message = "A .gitattributes file has been created in {$directory}.";
119+
if ($isAgenticRun) {
120+
$this->writeAgenticOutput($output, 'create', true, $message, ['gitattributes_file_path' => $gitattributesPath]);
121+
} else {
122+
$output->writeln($message);
123+
}
102124
return self::SUCCESS;
103125
}
104126
}

src/Commands/InitCommand.php

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Stolt\LeanPackage\Commands;
66

77
use Stolt\LeanPackage\Analyser;
8+
use Stolt\LeanPackage\Commands\Concerns\OutputOptions;
89
use Stolt\LeanPackage\Exceptions\PresetNotAvailable;
910
use Stolt\LeanPackage\Presets\CommonPreset;
1011
use Stolt\LeanPackage\Presets\Finder;
@@ -16,6 +17,8 @@
1617

1718
final class InitCommand extends Command
1819
{
20+
use OutputOptions;
21+
1922
private const DEFAULT_PRESET = 'PHP';
2023

2124
/**
@@ -82,6 +85,9 @@ protected function configure(): void
8285
InputOption::VALUE_NONE,
8386
'Do not write any files. Output the content that would be written'
8487
));
88+
$this->addAgenticOutputOption(function (...$args) {
89+
$this->getDefinition()->addOption(new InputOption(...$args));
90+
});
8591
}
8692

8793
/**
@@ -99,16 +105,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int
99105
$overwriteDefaultLpvFile = $input->getOption('overwrite');
100106
$chosenPreset = (string) $input->getOption('preset');
101107
$globPatternFromPreset = false;
108+
$isAgenticRun = $this->isAgenticRun($input);
102109

103110
if ($directory !== WORKING_DIRECTORY) {
104111
try {
105112
$this->analyser->setDirectory($directory);
106113
} catch (\RuntimeException $e) {
107114
$warning = "Warning: The provided directory "
108115
. "'$directory' does not exist or is not a directory.";
109-
$outputContent = '<error>' . $warning . '</error>';
110-
$output->writeln($outputContent);
111-
116+
if ($isAgenticRun) {
117+
$this->writeAgenticOutput($output, 'init', false, $warning);
118+
} else {
119+
$output->writeln('<error>' . $warning . '</error>');
120+
}
112121
$output->writeln($e->getMessage(), OutputInterface::VERBOSITY_DEBUG);
113122

114123
return Command::FAILURE;
@@ -122,9 +131,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
122131

123132
if (\file_exists($defaultLpvFile) && $overwriteDefaultLpvFile === false) {
124133
$warning = 'Warning: A default .lpv file already exists.';
125-
$outputContent = '<error>' . $warning . '</error>';
126-
$output->writeln($outputContent);
127-
134+
if ($isAgenticRun) {
135+
$this->writeAgenticOutput($output, 'init', false, $warning);
136+
} else {
137+
$output->writeln('<error>' . $warning . '</error>');
138+
}
128139
return Command::FAILURE;
129140
}
130141

@@ -135,9 +146,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
135146
$defaultGlobPattern = $this->finder->getPresetGlobByLanguageName($chosenPreset);
136147
} else {
137148
$warning = 'Warning: Chosen preset ' . $chosenPreset . ' is not available. Maybe contribute it?.';
138-
$outputContent = '<error>' . $warning . '</error>';
139-
$output->writeln($outputContent);
140-
149+
if ($isAgenticRun) {
150+
$this->writeAgenticOutput($output, 'init', false, $warning);
151+
} else {
152+
$output->writeln('<error>' . $warning . '</error>');
153+
}
141154
return Command::FAILURE;
142155
}
143156

@@ -164,14 +177,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int
164177

165178
if ($bytesWritten === false) {
166179
$warning = 'Warning: The creation of the default .lpv file failed.';
167-
$outputContent = '<error>' . $warning . '</error>';
168-
$output->writeln($outputContent);
180+
if ($isAgenticRun) {
181+
$this->writeAgenticOutput($output, 'init', false, $warning);
182+
} else {
183+
$output->writeln('<error>' . $warning . '</error>');
184+
}
169185

170186
return Command::FAILURE;
171187
}
172188

173-
$info = "<info>Created default '$defaultLpvFile' file.</info>";
174-
$output->writeln($info);
189+
$message = "Created default '$defaultLpvFile' file.";
190+
191+
if ($isAgenticRun) {
192+
$this->writeAgenticOutput($output, 'init', true, $message, ['lpv_file_path' => $defaultLpvFile]);
193+
} else {
194+
$output->writeln("<info>{$message}</info>");
195+
}
175196

176197
return Command::SUCCESS;
177198
}

0 commit comments

Comments
 (0)