Skip to main content
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)),
);
1

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.
2

Format as tree

The formatWorkspaceTree function groups packages by their parent directory and formats them with box-drawing characters.
3

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:
  1. Groups packages by their top-level directory (e.g., packages/, apps/)
  2. Uses Unicode box-drawing characters (├──, └──, ) to create the tree structure
  3. Shows both the directory name and package name for each entry
The same formatWorkspaceTree function is used in multiple places:
  • pm pls output
  • Root install warnings
  • Package not found error messages

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)),
);
1

List all packages

The command loads all workspace packages to search through.
2

Handle special cases

  • If --completions flag is present, output all package names for shell completion
  • If no package name is provided, output the monorepo root directory
3

Find the target package

Search for a package matching the provided name.
4

Output the absolute path

Print the resolved absolute path to stdout. The shell wrapper intercepts this and performs the actual directory change.
The pm cd command by itself only prints a path. It requires shell integration to actually change directories.

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:
1

Intercept pm cd calls

When you run pm cd <package>, the shell function intercepts it instead of calling the binary directly.
2

Handle flag-only invocations

If the arguments contain flags (like --completions or --path), pass through to the binary without changing directories.
3

Capture the output path

Execute command pm cd "$@" and capture the output (the package directory path).
4

Change directory

If the command succeeded and the output is a valid directory, use builtin cd to change the shell’s working directory.
5

Pass through other commands

For any command other than cd, call the pm binary directly.
The wrapper uses command pm to call the actual pm binary, avoiding infinite recursion with the wrapper function.

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:
1

Detect pm cd context

The completion function checks if the current command is pm cd and the cursor is on the package name argument.
2

Fetch package names

Run pm cd --completions to get a list of all workspace package names.
3

Generate completions

Pass the package names to the shell’s completion system (compadd for zsh, compgen for bash).
4

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;
}
The --completions flag is hidden from normal help output and is only used by the shell completion functions.

Package Discovery

Both pm pls and pm cd use the same underlying package discovery mechanism:
  1. Detect package manager - Find the lock file and determine if it’s pnpm, bun, or npm
  2. Read workspace config - Load workspace globs from pnpm-workspace.yaml or package.json
  3. Enumerate directories - Expand glob patterns and find all package directories
  4. Read package names - Parse each package.json to get the official package name
  5. 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 pls helps 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.