| title | Projector | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| tags |
|
Projector is a cross-platform CLI tool for managing project folders, their metadata, and links.
curl -sSfL https://raw.githubusercontent.com/gorodulin/prj/main/scripts/install.sh | shTo install a specific version:
VERSION=0.3.0 curl -sSfL https://raw.githubusercontent.com/gorodulin/prj/main/scripts/install.sh | shWorks in Docker containers (Alpine, slim, etc.). Installs to /usr/local/bin or ~/.local/bin.
Shell completions for bash, zsh, and fish are installed automatically when
standard completion directories are found. Restart your shell to activate.
brew tap gorodulin/tap
brew install prjShell completions for bash, zsh, and fish are installed automatically.
Try prj lis<Tab> to verify. If autocompletion doesn't work, see
Homebrew Shell Completion.
winget install gorodulin.prj
Requires Windows 10 1809 or later. For manual PowerShell install or more details, see docs/windows-distribution.md.
Requires Go 1.19+:
go install github.com/gorodulin/prj@latestOr clone and build:
git clone https://github.com/gorodulin/prj.git
cd prj
make installRun the interactive setup wizard:
prj initIt walks through machine identity, folders, and project ID format, and offers a native folder picker on macOS, Linux, and Windows. Then create a project:
prj new --title "My Project"If you'd rather configure manually, set the keys directly:
prj config set projects_folder /path/to/your/projects
prj config set metadata_folder /path/to/metadata
prj config set machine_name "my-laptop"
prj config set machine_id "$(uuidgen)"Add links_folder to organize projects by topic. See below.
List local projects with titles and tags.
prj list # ID, title, tags (local projects only)
prj list --all # include metadata-only projects (not present locally)
prj list --missing # show only metadata-only projects
prj list -f json # JSON array
prj list -f jsonl # one JSON object per line
prj list --json # shorthand for -f json
prj list --jsonl # shorthand for -f jsonl
prj list --tags wip,cli # filter by tags (AND logic)
prj list -q zfs # filter by substring across ID, title, tags
prj list | grep zfs # search across titles and tagsThe legacy --tag flag remains as a deprecated alias for --tags for
one release; it accepts the same comma-separated syntax and is no longer
repeatable.
The --format / -f flag controls output. It accepts a named format
or a Go text/template string:
prj list # default template: ID, title, tags
prj list -f json # JSON array with all fields
prj list -f jsonl # one JSON object per line
prj list -f '{{.ID}}' # custom Go templateDefault output (one project per line, colorized on TTY):
01J5B3GR41... + 26-04-02 prj (Golang) cli, golang
01J5B3H2K8... + 26-04-01 ZFS conversion automation, zfs
JSON output includes all fields (id, title, path, tags):
[
{
"id": "prj20260402a",
"title": "prj (Golang)",
"path": "/Users/.../prj20260402a",
"tags": ["cli", "golang"]
}
]Pass a Go template string to --format:
prj list -f '{{.ID}}' # just IDs
prj list -f '{{.ID}}\t{{.Title}}' # ID and title
prj list -f '{{.Tags | join ","}}' # comma-separated tags
prj list -f '{{.ID | green}}\t{{.Title}}' # with color (TTY only)
prj list -f '{{if .Title}}{{.Title}}{{end}}' # titles only, skip blanksAvailable fields: .ID, .Title, .Path, .Tags ([]string).
Template functions:
| Function | Example | Description |
|---|---|---|
join |
{{.Tags | join ","}} |
Join string slice with separator |
date |
{{.ID | date "YYYY-MM-DD"}} |
Extract date from project ID. Tokens: YYYY, YY, MM, DD, HH, mm, ss |
upper, lower |
{{.ID | upper}} |
Change case |
bold, dim |
{{.ID | bold}} |
Text styling (auto-disabled when piped) |
red, green, yellow, blue, cyan |
{{.Title | green}} |
Color (auto-disabled when piped) |
Escape sequences \t and \n are interpreted in template strings.
Set list_format in config to change the default (overridden by --format):
{
"list_format": "{{.ID}}\t{{.Title}}"
}Title is resolved from metadata if configured, otherwise from the first
# heading in the project's README.md, otherwise the project ID alone.
By default, only projects with a local folder are shown. --all includes
projects known only through metadata (useful for multi-machine sync).
--missing shows only metadata-only projects (not present locally).
--all and --missing are mutually exclusive.
# Get all project IDs
prj list -f '{{.ID}}'
# Loop over id + path pairs
prj list -f jsonl | jq -r '[.id, .path] | @tsv' | while IFS=$'\t' read -r id path; do
echo "backing up $id from $path"
done
# Find projects with a specific tag
prj list -f json | jq -r '.[] | select(.tags | index("golang")) | .id'
# Feed IDs into another command
prj list -f '{{.ID}}' | xargs -I{} prj path {}Create a new project with an auto-generated ID.
prj new # folder only
prj new --title "My Project" # + title
prj new --title "My Project" --tags "cli,golang" # + title and tags
prj new --title "My Project" --readme # + README.mdOutput: <id><TAB><path> (tab-separated, for use in scripts).
# Use in scripts:
id=$(prj new --title "Experiment" | cut -f1)--readme creates a README.md with YAML front matter:
---
title: My Project
tags: [cli, golang]
---
# My ProjectEdit project metadata (title and/or tags). Accepts one or more IDs.
prj edit prj20260402a --title "New Title" # set title
prj edit prj20260402a --tags "cli,golang" # replace all tags
prj edit prj20260402a --add-tags "new-tag" # add to existing tags
prj edit prj20260402a --remove-tags "old-tag" # remove specific tags
prj edit prj20260402a --title "" --tags "" # clear title and tags
prj edit current --add-tags "wip" # edit project in cwd
prj edit prj20260402a prj20260403b --add-tags wip # bulk: same edit on many
prj edit prj20260402a --add-tags wip --dry-run # preview without writing--tags and --add-tags/--remove-tags are mutually exclusive.
All tag operations (--tags, --add-tags, --remove-tags) apply to every ID
when multiple are given; only --title is rejected in bulk mode.
current is single-ID only. Duplicate IDs are deduped to one operation.
Output: <id><TAB><status><TAB><title> per project (status is updated,
created, or unchanged). Single-ID mode prints <id><TAB><title> for changes
and no changes to stderr when nothing changed.
Register a project that exists on another machine but not on this one:
prj edit prj20250101a --force --title "Remote Project" --tags "infra"--force creates metadata for a project even if its folder is not
present locally. The ID must still be a valid project ID format.
Pass --dry-run to compute what would change without writing metadata.
Output is identical to a real run except a DRY RUN — no metadata written
banner is printed to stderr first. In --json mode the envelope carries
"dry_run": true.
Pass --json for a uniform JSON envelope on stdout. The shape is the same
in all modes:
{
"error": null,
"results": [
{
"id": "prj20260402a",
"status": "updated",
"title": "New Title",
"path": "/Users/.../prj20260402a",
"local": true,
"tags": ["cli", "golang"],
"tags_added": ["golang"],
"tags_removed": []
}
],
"errors": []
}errorisnullon success or a{code, reason}object for invocation errors (e.g.title_unsupported_in_bulk,flags_conflict,no_flags_provided,config_load_failed). When set,resultsanderrorsare empty.results[]holds one entry per processed ID.errors[]holds per-ID failures (invalid_id_format,unknown_id,current_unsupported_in_bulk,metadata_io_failed).- Exit code is
1iferroris set orerrors[]is non-empty, else0. - Purge messages still go to stderr; the
no changesstderr line is suppressed (statusunchangedconveys it).
Pass --jsonl for line-oriented output: one JSON record per project on
stdout, plus one {"id":"…","error":{"code":"…","reason":"…"}} line per
per-ID failure. Top-level invocation errors collapse to a single error
line. Mutually exclusive with --json. In --jsonl mode there is no
envelope and no dry_run field; dry-run state is signaled only by the
stderr DRY RUN — no metadata written banner.
Display project details. Accepts one or more IDs.
prj info prj20260402a # human-readable
prj info prj20260402a prj20260403b # multiple projects, blank-line separated
prj info current # project in cwd (single-ID only)
prj info prj20260402a --json # JSON envelope
prj info prj20260402a prj20260403b --jsonl # one JSON object per lineHuman-mode bulk output prints each project's block in order, separated by
a blank line. Unknown IDs go to stderr; the command exits 1 on any
per-ID failure (partial failure mirrors prj edit).
Pass --json for a uniform JSON envelope on stdout. The shape matches
prj edit --json:
{
"error": null,
"results": [
{
"id": "prj20260402a",
"title": "My Project",
"path": "/Users/.../prj20260402a",
"tags": ["cli", "golang"],
"local": true,
"has_readme": true,
"date": "2026-04-02"
}
],
"errors": []
}errorisnullon success or{code, reason}for invocation errors (e.g.config_load_failed). When set,resultsanderrorsare empty.results[]holds one entry per resolved ID.dateis omitted when the ID format has no embedded timestamp;datetime_utcis omitted for the date-onlyaYYYYMMDDbformat.errors[]holds per-ID failures (invalid_id_format,unknown_id).- Exit code is
1iferroris set orerrors[]is non-empty, else0.
Breaking change: prior releases emitted a flat project object for
single-ID info --json. Scripts that read it should now use
.results[0]. Error responses also use the structured envelope
(error.code, error.reason) instead of {"error":"<msg>"}.
Pass --jsonl for line-oriented output: one JSON record per project on
stdout, plus one {"id":"…","error":{"code":"…","reason":"…"}} line per
per-ID failure. Mutually exclusive with --json.
Sync project links in a user-organized folder tree.
prj link # sync all projects
prj link prj20260402a # sync one project only
prj link current # sync project in cwd
prj link --dry-run # preview changes
prj link --verbose # include unchanged links in output
prj link --all # include metadata-only projects
prj link --kind symlink # override link type
prj link --warn-unplaced # list projects with no matching placementYou organize the link tree yourself — create folders for topics you care
about. prj link then places a link for each project into the folders
that match its tags. Folder names are converted to tags
(e.g. "Photo & Video" becomes two tags: photo and video; names are
split on &, lowercased, and spaces become underscores).
A project can appear in multiple folders if several match. If no folder
matches a project's tags and a fallback folder exists (see link_sink_name
in Config), the project is placed there instead.
Output shows what changed:
+ Programming/golang/prj (Golang) → prj20260402a
- Photos/Old Holiday (prj20250101a)
~ Work/cli/prj (Golang) → prj20260402a (wrong kind: want symlink)
2 created, 1 removed, 1 replaced
Only links created by Projector are touched. Regular files and directories in the link tree are never modified.
See docs/link-system.md for the full design.
Print the full path to a project folder.
prj path prj20260402a # /Users/.../projects/prj20260402a
prj path current # path of project in cwd
prj path prj20260402a --strict # error if folder doesn't existDefault: prints the path for any valid ID, warns on stderr if the folder doesn't exist locally. Useful as a path builder in scripts.
--strict: exits with error code 1 if the folder doesn't exist. Use in scripts that need the folder to be present.
# Path builder (always succeeds for valid IDs):
dir=$(prj path prj20250101a)
# Strict (fails if not synced):
dir=$(prj path prj20250101a --strict) || echo "not synced"Invalid ID format always errors regardless of --strict.
Repair cross-project symlinks inside project folders, rewriting their targets as machine-independent relative paths.
Stable sibling paths let you symlink one project from another (e.g. a playbook
project pointing at a shared Ansible-role project). When such links are created
with absolute paths, they break after syncing to a machine with a different
folder layout. prj fixrefs scans a project recursively for symlinks whose
target references a sibling project and rebuilds the path before the project ID
as a correct relative path. The suffix after the project ID is preserved.
prj fixrefs prj20260402a # preview (dry-run by default)
prj fixrefs prj20260402a --apply # rewrite the symlinks
prj fixrefs prj20260402a --verbose # also list unchanged symlinks
prj fixrefs current # scan project in cwd
prj fixrefs prj20260402a prj20260401b # scan several projectsA symlink is a candidate only when its target contains exactly one path
component that is a valid project ID for the configured project_id_type.
Targets with no project ID, two or more project IDs (ambiguous), or a foreign
ID format are left untouched. The target does not need to exist. Only symlinks
are modified — regular files and directories are never touched.
Default is a dry-run preview; pass --apply to perform the rewrites. The
operation is interruptible (Ctrl-C stops before any change). Output shows each
changed link as ~ <link> <old> → <new> and a summary line; exit code is 1
on any error.
fixrefs requires project_id_type to be configured (without it, no project
IDs are recognizable). It is unrelated to prj link, which manages the
separate links tree.
View and modify configuration from the command line.
prj config set projects_folder /path/to/projects
prj config set metadata_folder /path/to/metadata
prj config get projects_folder # print a single value
prj config list # show all keys and values
prj config path # print config file locationConfig file location (auto-detected per platform):
- macOS:
~/Library/Application Support/prj/config.json - Linux/BSD:
~/.config/prj/config.json - Windows:
%AppData%\prj\config.json
Only projects_folder is required. All other fields are optional and
enable additional features.
{
"projects_folder": "/path/to/projects",
"metadata_folder": "/path/to/metadata",
"list_format": "{{.ID}}\t{{.Title}}",
"links_folder": "/path/to/links",
"link_kind": "symlink",
"link_title_format": "{{.ID}} {{.Title}}",
"link_sink_name": "unsorted",
"project_id_type": "aYYYYMMDDb",
"project_id_prefix": "prj",
"machine_name": "Newton",
"machine_id": "newton",
"color": "auto"
}| Field | Description |
|---|---|
projects_folder |
(required) Root directory containing project folders |
metadata_folder |
Root directory for project metadata (titles, tags, edit history). Metadata directories are named <project-id>_meta |
project_id_type |
ID format for new projects: ULID (default), UUIDv7, KSUID, aYYYYMMDDb. See Choosing a project ID format |
project_id_prefix |
Prefix for aYYYYMMDDb IDs: 1-5 lowercase letters, optionally followed by - or _ (default: prj). Ignored by other formats |
machine_name |
Human-readable name for this machine (recorded in metadata) |
machine_id |
Machine identifier (recorded in metadata). Up to 36 characters, [a-zA-Z0-9_.-] only |
retention_days |
Automatically delete metadata entries older than N days. 0 = disabled (default) |
links_folder |
Root of the folder tree for prj link |
link_kind |
Link type: symlink (default) or finder-alias |
link_title_format |
Go template for link names. Fields: {{.Title}}, {{.ID}}. Supports same functions as list_format (date, upper, lower, etc.). Old {token} syntax is auto-migrated |
list_format |
Default output format for prj list: json, jsonl, or a Go template (e.g. "{{.ID}}\t{{.Title}}"). Overridden by --format flag |
link_sink_name |
Fallback folder name for projects that match no tag-based folder (empty = disabled) |
color |
Colored output mode: auto (default, on when stdout is a TTY), always, or never. The global --no-color flag overrides this |
The metadata folder stores project titles, tags, and their edit history.
It is separate from the projects folder — configure it via
prj config set metadata_folder /path/to/metadata.
With metadata configured, you can:
- See titles and tags in
prj listoutput - Edit titles and tags with
prj edit - List projects that aren't present on this machine (
prj list --allor--missing)
Projector does not sync files itself. Use a file sync tool (Resilio Sync,
Syncthing, etc.) to sync projects_folder and metadata_folder across
machines. Metadata folders from different machines can be merged freely —
just combine their contents into one folder.
See docs/metadata-system.md for the internal design.
Metadata files accumulate over time but are tiny (~1KB each). To
automatically delete old entries, set retention_days in config:
{ "retention_days": 180 }Cleanup runs after prj new and prj edit. At least 2 entries per
project are always kept. Disabled by default.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Error (invalid args, missing config, folder creation failed, etc.) |
Warnings (e.g. missing project folder in non-strict mode) go to stderr but don't affect the exit code.
Every project folder is named by its ID. Once you pick a format, all future projects use it and existing folders stay as they are — there are no aliases or migration tools. Choose carefully.
Set the format via project_id_type in config. Default: ULID.
| ULID | UUIDv7 | KSUID | aYYYYMMDDb | |
|---|---|---|---|---|
| Example | 01J5B3GR41TSV4RRPQD3NGHX42 |
01932c07-a9c3-7b2a-8f1a-6b3c9d4e5f67 |
2E8JwMKbBEgHvAsD9kNLRqpTiS0 |
prj20260402a |
| Length | 26 | 36 (with dashes) | 27 | 12-16 |
| Characters | 0-9 A-Z (no I, L, O, U) |
0-9 a-f + dashes |
0-9 A-Z a-z |
a-z 0-9 - _ |
| Time precision | Milliseconds | Milliseconds | Seconds | Day |
| Lexicographic sort | Yes | Yes | Yes | Yes |
| Case-sensitive | No | No | Yes | No |
| Globally unique | Yes | Yes | Yes | No (needs collision check) |
| Filesystem-safe | All OS | All OS | Rare risk on case-insensitive FS | All OS |
ULID (default) — best general-purpose choice. Short, case-insensitive, globally unique without collision checks. Safe on macOS (APFS/HFS+) and Windows (NTFS) which are case-insensitive by default. Crockford Base32 avoids ambiguous characters (0/O, 1/I/L).
aYYYYMMDDb — the best choice for personal projects. Human-friendly,
date-based, short and readable (prj20260402a). You can create folders
by hand and instantly see when a project was started. The prefix is
configurable via project_id_prefix (default: prj). Proven in practice
over 20 years of personal project management. Not globally unique across
machines — requires scanning existing IDs to avoid collisions.
UUIDv7 — use if your tooling expects standard UUIDs. Same time precision as ULID but longer (36 chars with dashes).
KSUID — use with caution. Mixed-case Base62 means 2E8Jw and 2e8jw
are different IDs but point to the same folder on macOS and Windows.
Only safe on case-sensitive filesystems (Linux ext4, ZFS). Collision risk
is extremely low in practice.
make help # show all targets
make build # compile
make check # test + lint
make cover # HTML coverage report
make cross # cross-compile all platforms