| 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.
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 install-
Configure the projects folder:
prj config set projects_folder /path/to/your/projects -
Create your first project:
prj new --title "My Project"
That's the minimum setup. To track titles and tags, configure metadata:
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 | grep zfs # search across titles and tagsThe --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).
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--tags and --add-tags/--remove-tags are mutually exclusive.
Output: <id><TAB><title> (or just <id> if no title).
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.
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.
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"
}| 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) |
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) |
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