Workspace Navigation
How pm cd and pm pls enable fast navigation across workspace packages
Better PM provides two commands for discovering and navigating workspace packages: pm pls for listing packages and pm cd for jumping to package directories.
Package Listing with pm pls
The pm pls command displays all workspace packages in a tree structure:
pm pls
├── packages/
│ ├── core "@myapp/core"
│ └── utils "@myapp/utils"
└── apps/
└── web "@myapp/web"
How It Works
The pls command is remarkably simple:
// From src/commands/pls.ts:8-17
export const plsCmd = cli.Command.make('pls', {}, () =>
Effect.gen(function* () {
const pm = yield* PackageManagerService;
const path = yield* Path.Path;
const packages = yield* pm.listWorkspacePackages(pm.lockDir);
for (const line of formatWorkspaceTree(packages, path.sep)) {
yield* Console.log(line);
}
}).pipe(Effect.provide(PackageManagerLayer)),
);
List workspace packages
The package manager service discovers all workspace packages by reading the workspace configuration (pnpm-workspace.yaml or package.json workspaces field) and enumerating directories.
Format as tree
The formatWorkspaceTree function groups packages by their parent directory and formats them with box-drawing characters.
Display to console
Each line of the formatted tree is printed to stdout.
Tree Formatting Logic
The tree structure is generated by the formatWorkspaceTree function:
// From src/lib/format-workspace-tree.ts:1-34
export const formatWorkspaceTree = (
packages: Array<{ name: string; relDir: string }>,
sep: string,
) => {
const grouped = new Map<string, Array<{ name: string; dirName: string }>>();
for (const pkg of packages) {
const parts = pkg.relDir.split(sep);
const group = parts[0];
const dirName = parts.slice(1).join(sep);
if (!grouped.has(group)) {
grouped.set(group, []);
}
grouped.get(group)!.push({ name: pkg.name, dirName });
}
const lines: Array<string> = [];
const groupEntries = Array.from(grouped.entries());
for (let gi = 0; gi < groupEntries.length; gi++) {
const [group, entries] = groupEntries[gi];
const isLastGroup = gi === groupEntries.length - 1;
const groupPrefix = isLastGroup ? '└── ' : '├── ';
const childIndent = isLastGroup ? ' ' : '│ ';
lines.push(`${groupPrefix}${group}/`);
for (let ei = 0; ei < entries.length; ei++) {
const entry = entries[ei];
const isLastEntry = ei === entries.length - 1;
const entryPrefix = isLastEntry ? '└── ' : '├── ';
lines.push(
`${childIndent}${entryPrefix}${entry.dirName} "${entry.name}"`,
);
}
}
return lines;
};
The function:
- Groups packages by their top-level directory (e.g.,
packages/,apps/) - Uses Unicode box-drawing characters (
├──,└──,│) to create the tree structure - Shows both the directory name and package name for each entry
Directory Navigation with pm cd
The pm cd command allows you to jump directly to any workspace package:
pm cd @myapp/web # cd to apps/web
pm cd # cd to monorepo root
Command Implementation
// From src/commands/cd.ts:22-57
export const cdCmd = cli.Command.make(
'cd',
{ packageName: packageNameArg, completions: completionsOption, path: pathOption },
(args) =>
Effect.gen(function* () {
const pm = yield* PackageManagerService;
const path = yield* Path.Path;
const packages = yield* pm.listWorkspacePackages(pm.lockDir);
if (args.completions) {
yield* Effect.forEach(packages, (pkg) => Console.log(pkg.name), {
concurrency: 'unbounded',
});
return;
}
if (Option.isNone(args.packageName)) {
yield* Console.log(pm.lockDir);
return;
}
const packageName = args.packageName.value;
const pkg = packages.find((p) => p.name === packageName);
if (!pkg) {
return yield* Effect.fail(
new PackageNotFoundError(
packageName,
formatWorkspaceTree(packages, path.sep),
),
);
}
yield* Console.log(path.resolve(pm.lockDir, pkg.relDir));
}).pipe(Effect.provide(PackageManagerLayer)),
);
List all packages
The command loads all workspace packages to search through.
Handle special cases
- If
--completionsflag is present, output all package names for shell completion - If no package name is provided, output the monorepo root directory
Find the target package
Search for a package matching the provided name.
Output the absolute path
Print the resolved absolute path to stdout. The shell wrapper intercepts this and performs the actual directory change.
Shell Integration Mechanism
Better PM can’t change your shell’s working directory directly (child processes can’t modify parent state). Instead, it uses a shell wrapper function:
Activation
Add this to your .zshrc or .bashrc:
eval "$(pm activate zsh)" # or bash
The Shell Wrapper
The pm activate command outputs a shell function that wraps the pm binary:
# From src/commands/activate.ts:6-22
pm() {
if [ "$1" = "cd" ]; then
shift;
for arg in "$@"; do
case "$arg" in
-*) command pm cd "$@"; return;;
esac;
done;
local dir;
dir=$(command pm cd "$@");
if [ $? -eq 0 ] && [ -d "$dir" ]; then
builtin cd "$dir";
fi;
else
command pm "$@";
fi;
};
How it works:
Intercept pm cd calls
When you run pm cd <package>, the shell function intercepts it instead of calling the binary directly.
Handle flag-only invocations
If the arguments contain flags (like --completions or --path), pass through to the binary without changing directories.
Capture the output path
Execute command pm cd "$@" and capture the output (the package directory path).
Change directory
If the command succeeded and the output is a valid directory, use builtin cd to change the shell’s working directory.
Pass through other commands
For any command other than cd, call the pm binary directly.
Autocomplete Functionality
Better PM provides shell completion for package names:
Zsh Completions
# From src/commands/activate.ts:24-34
eval "$(command pm --completions zsh)";
if (( $+functions[_pm_zsh_completions] )); then
functions[_pm_zsh_completions_base]=$functions[_pm_zsh_completions];
_pm_zsh_completions() {
if [[ $words[2] == cd ]] && (( CURRENT == 3 )); then
compadd -- ${(f)"$(command pm cd --completions 2>/dev/null)"};
else
_pm_zsh_completions_base "$@";
fi;
};
fi;
Bash Completions
# From src/commands/activate.ts:36-44
eval "$(command pm --completions bash)";
_pm_custom_completions() {
if [[ "${COMP_WORDS[1]}" == "cd" ]] && [[ $COMP_CWORD -eq 2 ]]; then
COMPREPLY=($(compgen -W "$(command pm cd --completions 2>/dev/null)" -- "${COMP_WORDS[$COMP_CWORD]}"));
return;
fi;
_pm_bash_completions;
};
complete -F _pm_custom_completions -o nosort -o bashdefault -o default pm;
How autocomplete works:
Detect pm cd context
The completion function checks if the current command is pm cd and the cursor is on the package name argument.
Fetch package names
Run pm cd --completions to get a list of all workspace package names.
Generate completions
Pass the package names to the shell’s completion system (compadd for zsh, compgen for bash).
Fall back to base completions
For any other command, use the default CLI completions generated by Effect CLI.
Completions Mode
When pm cd --completions is called, it outputs one package name per line:
// From src/commands/cd.ts:31-35
if (args.completions) {
yield* Effect.forEach(packages, (pkg) => Console.log(pkg.name), {
concurrency: 'unbounded',
});
return;
}
Package Discovery
Both pm pls and pm cd use the same underlying package discovery mechanism:
- Detect package manager - Find the lock file and determine if it’s pnpm, bun, or npm
- Read workspace config - Load workspace globs from
pnpm-workspace.yamlorpackage.json - Enumerate directories - Expand glob patterns and find all package directories
- Read package names - Parse each
package.jsonto get the official package name - Return structured data - Provide an array of
{ name: string; relDir: string }objects
This ensures consistency between listing and navigation - you can always pm cd to any package shown in pm pls.
Fast Navigation
Jump to any package from anywhere in the monorepo without manually typing paths or using fuzzy finders.
Autocomplete Support
Tab completion for package names makes navigation even faster and prevents typos.
Visual Discovery
The tree view in
pm plshelps you understand your monorepo structure at a glance.
Error Guidance
If you try to cd to a non-existent package, Better PM shows the full package tree to help you find the right name.