Dist-Zilla-Plugin-GatherAgentContext
view release on metacpan or search on metacpan
lib/Dist/Zilla/Plugin/GatherAgentContext.pm view on Meta::CPAN
# A harness directory is also where the harness and the user park local state:
# login tokens, session transcripts, todos, caches, backups. This plugin reads
# from disk, so git-ignoring those does not keep them out of a tarball. Below
# such a directory only these top-level entries are agent content; whatever
# else is there is never read, no matter what it is called -- a list of things
# to skip would publish every name nobody thought of.
my %HARNESS_DIR = map { $_ => 1 } qw(.claude .codex);
my %CONTENT_DIR = map { $_ => 1 } qw(agents skills rules hooks commands);
my %CONTENT_FILE = map { $_ => 1 }
qw(CLAUDE.md skilletor.json skilletor.lock.json skilletor-rules.md);
has harness => (is => 'ro', isa => 'ArrayRef[Str]', lazy => 1, default => sub { [] });
has dir => (is => 'ro', isa => 'ArrayRef[Str]', lazy => 1, default => sub { [] });
has file => (is => 'ro', isa => 'ArrayRef[Str]', lazy => 1, default => sub { [] });
has to => (is => 'ro', isa => 'Str', default => 'misc/agent-context');
has exclude_match => (is => 'ro', isa => 'ArrayRef[Str]', lazy => 1, default => sub { [] });
has prune_gitignore => (is => 'ro', isa => 'Bool', default => 1);
has missing_ok => (is => 'ro', isa => 'Bool', default => 1);
sub mvp_multivalue_args { qw(harness dir file exclude_match) }
sub _selected_harness {
my ($self) = @_;
my @h = @{ $self->harness };
return @HARNESS_ORDER if !@h || grep { $_ eq 'all' } @h;
for my $h (@h) {
$self->log_fatal("unknown harness '$h' (known: @HARNESS_ORDER, all)")
unless $HARNESS{$h};
}
return @h;
}
sub _dirs {
my ($self) = @_;
return @{ $self->dir } if @{ $self->dir };
my (%seen, @dirs);
for my $h ($self->_selected_harness) {
push @dirs, grep { !$seen{$_}++ } @{ $HARNESS{$h}{dir} };
}
return @dirs;
}
sub _files {
my ($self) = @_;
return @{ $self->file } if @{ $self->file };
my (%seen, @files);
for my $h ($self->_selected_harness) {
push @files, grep { !$seen{$_}++ } @{ $HARNESS{$h}{file} };
}
return @files;
}
sub _exclude_res {
my ($self) = @_;
my @res = (
# <name>.local.<ext>: machine-local overrides (settings.local.json,
# skilletor.local.json, CLAUDE.local.md) and their backups (.local.json.bak).
# A leading-dot `.local.<name>.md` is skilletor-installed content and stays.
qr{(?:^|/)[^/]+\.local\.[^/]+$},
qr{(?:^|/)(?:\.credentials|auth)\.json$}, # harness login tokens
qr{(?:^|/)settings\.json$}, # editor/permission config, not agent content
qr{(?:^|/)worktrees/},
qr{(?:^|/)\.DS_Store$},
);
push @res, qr{(?:^|/)\.gitignore$} if $self->prune_gitignore;
push @res, map { qr/$_/ } @{ $self->exclude_match };
return @res;
}
sub _excluded {
my ($self, $relpath) = @_;
for my $re ($self->_exclude_res) { return 1 if $relpath =~ $re }
return 0;
}
sub _snapshot {
my ($self, $src, $base) = @_;
my $rel = $src->relative($base);
return if $self->_excluded("$rel");
# Strict UTF-8 decode: a binary/non-UTF-8 file must fail loud, not be
# silently substituted (the lenient :encoding(UTF-8) layer would replace).
my $content = eval { Encode::decode('UTF-8', $src->slurp_raw, Encode::FB_CROAK) };
$self->log_fatal(
"agent-context file '$rel' is not valid UTF-8 (binary content not supported)")
unless defined $content;
$self->add_file(Dist::Zilla::File::InMemory->new(
name => $self->to . "/$rel",
content => $content,
));
$self->log_debug("gathered @{[$self->to]}/$rel");
}
sub gather_files {
my ($self) = @_;
my $base = path($self->zilla->root)->absolute;
for my $relfile ($self->_files) {
$self->log_fatal("agent-context path '$relfile' must be relative")
if path($relfile)->is_absolute;
my $src = $base->child($relfile);
unless ($src->is_file) {
$self->log_fatal("agent-context file '$relfile' not found under @{[$self->zilla->root]}")
unless $self->missing_ok;
next;
}
$self->_snapshot($src, $base);
}
for my $reldir ($self->_dirs) {
$self->log_fatal("agent-context path '$reldir' must be relative")
if path($reldir)->is_absolute;
my $dir = $base->child($reldir);
unless ($dir->is_dir) {
$self->log_fatal("agent-context dir '$reldir' not found under @{[$self->zilla->root]}")
unless $self->missing_ok;
next;
}
$self->_gather_dir($dir, $base);
}
}
lib/Dist/Zilla/Plugin/GatherAgentContext.pm view on Meta::CPAN
harness keeps next to it. See L</WHAT IS SNAPSHOTTED>.
=head2 harness
Which harness default sets to snapshot: C<claude>, C<codex> (repeatable), or
C<all>. Unset means all. Ignored once C<dir>/C<file> are set explicitly.
=head2 dir
Directories (repo-relative) to snapshot recursively. Overrides the
harness-derived directory defaults. A harness directory (C<.claude>, C<.codex>)
is reduced to its agent content even when named here; see
L</WHAT IS SNAPSHOTTED> for how to take more.
=head2 file
Single repo-relative files to snapshot. Overrides the harness-derived file
defaults.
=head2 to
Base path inside the build. Default C<misc/agent-context>.
=head2 exclude_match
Additional exclude regexes (repeatable), applied on top of the built-in ones
(see L</BUILT-IN EXCLUDES>).
=head2 prune_gitignore
Skip C<.gitignore> files (skilletor's per-skill management marker). Default 1.
=head2 missing_ok
Silently skip a configured dir/file that is absent. Default 1. Set to 0 to make
an absent configured path fatal.
=head1 WHAT IS SNAPSHOTTED
A harness directory â any directory named C<.claude> or C<.codex> â is not only
agent context. The harness and the user also keep local state there: login
tokens, session transcripts, todo lists, caches, local overrides, backups. This
plugin reads from disk, so a C<.gitignore> does not keep any of that out of the
tarball. Directly below a harness directory the plugin therefore takes B<only>
these entries and never opens anything else:
=over 4
=item *
the directories C<agents/>, C<skills/>, C<rules/>, C<hooks/> and C<commands/>,
recursively;
=item *
the files C<CLAUDE.md>, C<skilletor.json>, C<skilletor.lock.json> and
C<skilletor-rules.md>.
=back
So C<.claude/.credentials.json>, C<.claude/projects/>, C<.claude/todos/>,
C<.claude/statsig/>, C<.claude/local/>, C<.codex/auth.json>,
C<.codex/config.toml> and anything else a harness or a person drops there stay
out, whatever the file is called. This holds for a harness directory named
explicitly through L</dir> and for one met deeper inside a configured tree.
Other directories (C<.agents/skills>, or your own L</dir> entries) are taken in
full. To snapshot a further subdirectory of a harness directory, configure it by
name â it is then an ordinary directory:
[GatherAgentContext]
dir = .claude
dir = .claude/output-styles
dir = .codex
dir = .agents/skills
The L</BUILT-IN EXCLUDES> apply on top, everywhere.
=head1 BUILT-IN EXCLUDES
These paths are always skipped, wherever they are found and before any
L</exclude_match> additions:
=over 4
=item *
Any C<< <name>.local.<ext> >> file (for example C<settings.local.json>,
C<skilletor.local.json>, C<CLAUDE.local.md>, or a backup such as
C<settings.local.json.bak>) â machine-local overrides, not agent content.
Skilletor-installed agents and rules named C<< .local.<name>.md >> (leading
dot) are content and are kept.
=item *
C<.credentials.json> and C<auth.json> â harness login tokens.
=item *
C<settings.json> â editor and permission configuration, not agent content.
=item *
Any C<worktrees/> directory â agent worktrees hold full checkouts (thousands
of files). Such directories are pruned before descent, not walked and then
discarded leaf by leaf.
=item *
C<.DS_Store>.
=item *
C<.gitignore> files, unless L</prune_gitignore> is set to 0 (skilletor drops a
per-skill C<.gitignore> as a management marker).
=back
Any excluded directory is pruned before the plugin descends into it, so its
contents are never stat-walked.
=head1 ENCODING AND SYMLINKS
Files are read and decoded as strict UTF-8. A binary or otherwise non-UTF-8
file aborts the build with a fatal error rather than being silently
substituted â snapshot textual agent context, not binaries.
Within the recursive directory walk, symlinks are never followed â a symlinked
file or subdirectory found while descending is skipped. A top-level path given
explicitly through L</dir> or L</file> is resolved normally, so a configured
entry that is itself a symlink is still read.
=head1 SUPPORT
=head2 Issues
Please report bugs and feature requests at
L<https://github.com/Getty/p5-dist-zilla-plugin-gatheragentcontext/issues>.
=head1 CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
=head1 AUTHOR
Torsten Raudssus <getty@cpan.org>
=head1 COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> L<https://raudssus.de/>.
This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.
=cut
( run in 0.929 second using v1.01-cache-2.11-cpan-3f962061493 )