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 )