Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,18 @@ Config is loaded from `crm.toml`. Resolution order (first match wins):

1. `--config <path>` flag (explicit)
2. `CRM_CONFIG` env var
3. Walk up from CWD: `./crm.toml` → `../crm.toml` → `../../crm.toml` → ... → `/crm.toml`
4. `~/.crm/config.toml` (global fallback)
3. Walk up from CWD, but never past the current git repository's root: `./crm.toml` → `../crm.toml` → ... → `<repo root>/crm.toml`
4. If CWD isn't inside a git repository at all, only `./crm.toml` is checked (no upward walk)

This means you can drop a `crm.toml` in your project root and it applies to everyone working in that directory — just like `.gitignore` or `biome.jsonc`.
There is no global `~/.crm/config.toml` fallback — config is always scoped to the current project. This means you can drop a `crm.toml` in your project root and it applies to everyone working in that directory — just like `.gitignore` or `biome.jsonc`.

```bash
# Project-scoped config
echo '[pipeline]
stages = ["discovery", "demo", "trial", "closed-won", "closed-lost"]' > ./crm.toml

# Global config (applies everywhere unless overridden)
mkdir -p ~/.crm
cat > ~/.crm/config.toml << 'EOF'
# Full example, placed at the project root
cat > ./crm.toml << 'EOF'
[database]
path = "~/.crm/crm.db"

Expand Down Expand Up @@ -122,7 +121,7 @@ search_limit = 20 # max results from search/find
EOF
```

Settings in a closer `crm.toml` override the global config. The `--config` flag overrides everything.
Settings in a closer `crm.toml` override one further up the repo. The `--config` flag overrides everything.

---

Expand Down Expand Up @@ -782,7 +781,7 @@ crm contact list --format json | jq '.[] | select(.tags | contains(["hot-lead"])

### Hooks

Shell commands triggered on mutations. Configured in `~/.crm/config.toml`:
Shell commands triggered on mutations. Configured in the project's own `crm.toml` (see [Configuration](#configuration) — there is no global config file):

```toml
[hooks]
Expand All @@ -797,6 +796,22 @@ Available hooks:

- `pre-*` / `post-*` for: `contact-add`, `contact-edit`, `contact-rm`, `company-add`, `company-edit`, `company-rm`, `deal-add`, `deal-edit`, `deal-rm`, `deal-stage-change`, `activity-add`

#### Trust on first use

Hooks are shell commands, so a `crm.toml` picked up via the implicit discovery walk (i.e. not passed via `--config` or `CRM_CONFIG`) could belong to an ancestor directory you don't fully control. Its `[hooks]` therefore require explicit trust before they run — the same model direnv/mise use for `.envrc`/`.mise.toml`:

- On first use, if you're at an interactive terminal, crm prompts: run this hook once and remember the file (path + content hash), or skip it.
- Non-interactively (CI, scripts, no TTY) an untrusted hook is always skipped — the command still completes normally, and a warning is printed to stderr telling you which config to trust.
- If a trusted `crm.toml`'s content later changes, trust is invalidated (the hash no longer matches) and it must be re-trusted.
- A config supplied explicitly via `--config <path>` or `CRM_CONFIG` is a deliberate action and its hooks always run — no trust step.

```bash
crm config trust ./crm.toml # trust a config's hooks; omit the path to trust whatever config would be auto-resolved
crm config untrust ./crm.toml # revoke trust
```

Trust decisions are stored locally in `~/.crm/trusted_configs.json` (path → content hash only — never config content, and never read as a config source itself).

---

## Custom Fields
Expand Down
8 changes: 5 additions & 3 deletions skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ crm --version

## Configuration

Optional. Create `crm.toml` in your project root or `~/.crm/config.toml`:
Optional. Create `crm.toml` in your project root:

```toml
[database]
Expand All @@ -52,7 +52,7 @@ display = "international"
default_path = "~/crm"
```

Config is auto-discovered by walking up from the current directory. Override with `--config <path>` or `CRM_CONFIG` env var.
Config is auto-discovered by walking up from the current directory, but never past the current git repository's root (if CWD isn't inside a git repository, only the current directory is checked). There is no global `~/.crm/config.toml` fallback. Override with `--config <path>` or `CRM_CONFIG` env var.

## Global Flags

Expand Down Expand Up @@ -450,7 +450,7 @@ Prefix with `json:` for typed values (numbers, booleans, arrays).

## Hooks

Configure shell hooks in `crm.toml` that fire on mutations:
Configure shell hooks in the project's own `crm.toml` that fire on mutations (there is no global config file):

```toml
[hooks]
Expand All @@ -463,6 +463,8 @@ Entity data is passed as JSON on stdin. Pre-hooks abort on non-zero exit.

Available hooks: `{pre,post}-{contact,company,deal}-{add,edit,rm}`, `{pre,post}-deal-stage-change`, `{pre,post}-activity-add`.

Hooks in an implicitly-discovered `crm.toml` (not passed via `--config`/`CRM_CONFIG`) require trust-on-first-use: interactively you'll be prompted once (approval is remembered by path + content hash); non-interactively an untrusted hook is skipped with a warning rather than run silently. Run `crm config trust ./crm.toml` to trust one ahead of time. Configs passed explicitly via `--config`/`CRM_CONFIG` are exempt.

## Tips for AI Agents

- **Mount first:** `crm mount ~/crm` gives you filesystem access — read JSON files directly instead of running CLI commands
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
registerLogCommand,
} from './commands/activity'
import { registerCompanyCommands } from './commands/company'
import { registerConfigCommands } from './commands/config'
import { registerContactCommands } from './commands/contact'
import { registerDealCommands, registerPipelineCommand } from './commands/deal'
import { registerDupesCommand } from './commands/dupes'
Expand Down Expand Up @@ -42,6 +43,7 @@ registerReportCommands(program)
registerImportExportCommands(program)
registerDupesCommand(program)
registerFuseCommands(program)
registerConfigCommands(program)

// Hidden subcommand: runs the FUSE daemon in-process (used by `crm mount`)
if (cleanArgv[0] === '__daemon') {
Expand Down
51 changes: 51 additions & 0 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { existsSync } from 'node:fs'

import type { Command } from 'commander'

import { resolveConfigPath } from '../config'
import { die, gConfig } from '../lib/helpers'
import { trustConfig, untrustConfig } from '../trust-store'

/** Resolve the target config path for `config trust`/`config untrust` when
* no explicit path argument is given: respect the same precedence
* (--config > CRM_CONFIG > implicit discovery) the rest of the CLI uses. */
function resolveTarget(path: string | undefined): string {
const target = path || resolveConfigPath(gConfig).path
if (!target) {
die(
'Error: no crm.toml found to trust — pass a path explicitly, e.g. `crm config trust ./crm.toml`',
)
}
if (!existsSync(target)) {
die(`Error: config file not found: ${target}`)
}
return target
}

export function registerConfigCommands(program: Command) {
const cmd = program.command('config').description('Manage crm.toml trust')

cmd
.command('trust [path]')
.description(
'Trust a crm.toml so its [hooks] run without a confirmation prompt',
)
.action((path?: string) => {
const target = resolveTarget(path)
trustConfig(target)
console.log(
`Trusted ${target} — hooks defined in it will now run without prompting.`,
)
})

cmd
.command('untrust [path]')
.description('Revoke trust for a crm.toml')
.action((path?: string) => {
const target = resolveTarget(path)
const removed = untrustConfig(target)
console.log(
removed ? `Untrusted ${target}.` : `${target} was not trusted.`,
)
})
}
134 changes: 114 additions & 20 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import { dirname, join, resolve } from 'node:path'
import { parse as parseTOML } from 'toml'

export interface CRMConfig {
/**
* Resolution metadata — not part of the TOML schema. Populated by
* `loadConfig` so callers (notably the hooks trust gate in `hooks.ts`)
* can tell whether this config came from an explicit source (`--config`
* / `CRM_CONFIG`) or was discovered implicitly, since only implicitly
* discovered configs are subject to the hooks trust-on-first-use gate.
*/
_meta?: ConfigResolution
database: { path: string }
defaults: { format: string }
hooks: Record<string, string>
Expand All @@ -26,6 +34,13 @@ export interface CRMConfig {
pipeline: { stages: string[]; won_stage: string; lost_stage: string }
}

export type ConfigSource = 'explicit' | 'implicit' | 'none'

export interface ConfigResolution {
path: string | null
source: ConfigSource
}

export const SEARCH_MODEL = 'mxbai-embed-xsmall-v1'

const DEFAULT_STAGES = [
Expand Down Expand Up @@ -58,24 +73,89 @@ function defaultConfig(): CRMConfig {
}
}

/**
* Find the real git repository root containing `startDir`, by shelling out
* to `git rev-parse --show-toplevel`. This is the only trustworthy way to
* establish a project-root boundary: unlike checking for a `.git` path with
* `existsSync`, it can't be spoofed by planting an arbitrary file or
* directory named `.git` in an ancestor directory, and it correctly handles
* worktrees, submodules, and `.git` files (vs. directories).
*
* Returns `null` if `startDir` is not inside a git repository at all (or
* `git` isn't installed) — in that case there is no project-root boundary
* to find, and callers must not search upward toward the filesystem root.
*/
function findProjectRoot(startDir: string): string | null {
try {
const out = execSync('git rev-parse --show-toplevel', {
cwd: startDir,
stdio: ['pipe', 'pipe', 'pipe'],
})
.toString()
.trim()
return out ? resolve(out) : null
} catch {
return null
}
}

/**
* Search for `crm.toml` starting at `startDir` and walking up parent
* directories, but never past the project root (see `findProjectRoot`).
* This prevents an unrelated ancestor directory's `crm.toml` — whose
* `hooks` are executed without confirmation — from being loaded.
*
* If `startDir` isn't inside a real git repository, there is no known
* project boundary, so only `startDir` itself is checked — never walking
* upward toward the filesystem root.
*
* There is no implicit fallback to a global `~/.crm/config.toml`: if no
* `crm.toml` is found within the project, callers fall back to the
* built-in default config.
*/
function findConfigFile(startDir: string): string | null {
let dir = resolve(startDir)
const root = findProjectRoot(startDir)
const start = resolve(startDir)

if (root === null) {
const candidate = join(start, 'crm.toml')
return existsSync(candidate) ? candidate : null
}

let dir = start
while (true) {
const candidate = join(dir, 'crm.toml')
if (existsSync(candidate)) {
return candidate
}
if (dir === root) {
return null
}
const parent = dirname(dir)
if (parent === dir) {
break
return null
}
dir = parent
}
const global = join(homedir(), '.crm', 'config.toml')
if (existsSync(global)) {
return global
}

/**
* Resolve which `crm.toml` (if any) `loadConfig` would load, without
* reading or parsing it, and report whether that resolution was explicit
* (deliberate user action: `--config` flag or `CRM_CONFIG` env var) or
* implicit (discovered by searching cwd-or-upward). Only implicit
* resolution is subject to the hooks trust gate — see `hooks.ts`.
*/
export function resolveConfigPath(explicitPath?: string): ConfigResolution {
const explicit = explicitPath || process.env.CRM_CONFIG || null
if (explicit) {
return { path: resolve(explicit), source: 'explicit' }
}
return null
const found = findConfigFile(process.cwd())
if (found) {
return { path: found, source: 'implicit' }
}
return { path: null, source: 'none' }
}

function mergeConfig(
Expand Down Expand Up @@ -178,24 +258,38 @@ export function loadConfig(opts: {
let config = defaultConfig()

// Resolve config file — auto-create with sensible defaults on first run
const configPath =
opts.configPath ||
process.env.CRM_CONFIG ||
findConfigFile(process.cwd()) ||
(() => {
const p = join(homedir(), '.crm', 'config.toml')
const resolved = resolveConfigPath(opts.configPath)
let configPath: string | null = resolved.path
let source: ConfigSource = resolved.source

if (!configPath) {
const root = findProjectRoot(process.cwd())
const p = join(root ?? process.cwd(), 'crm.toml')
try {
createDefaultConfig(p)
return p
})()
configPath = p
// Auto-created configs are found the same way an implicit crm.toml
// would be on the next run — treat them as implicit for the hooks
// trust gate rather than exempting them.
source = 'implicit'
} catch (_e) {
console.error(`Warning: could not create default config at ${p}`)
configPath = null
}
}

try {
const raw = readFileSync(configPath, 'utf-8')
const parsed = parseTOML(raw)
config = mergeConfig(config, parsed)
} catch (_e) {
console.error(`Warning: could not parse config file ${configPath}`)
if (configPath) {
try {
const raw = readFileSync(configPath, 'utf-8')
const parsed = parseTOML(raw)
config = mergeConfig(config, parsed)
} catch (_e) {
console.error(`Warning: could not parse config file ${configPath}`)
}
}

config._meta = { path: configPath, source }

// Env var overrides (take priority over config file)
if (process.env.CRM_PHONE_DEFAULT_COUNTRY) {
config.phone.default_country = process.env.CRM_PHONE_DEFAULT_COUNTRY
Expand Down
Loading