ts-axi 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,9 +11,9 @@ $ ts-axi impact main...HEAD
11
11
  base: 923fb4d7ddc8f4e5c4df9029be388f8cb29ce095
12
12
  head: HEAD
13
13
  changes: 2
14
- changed[2]{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}:
15
- formatPrice,function,signature,true,"src/lib/format.ts:5",13,12,0,6,2
16
- Cart.total,method,body,true,"src/lib/cart.ts:32",8,8,0,5,3
14
+ changed[2]{symbol,change,at,refs_outside}:
15
+ formatPrice,signature,"src/lib/format.ts:5",12
16
+ Cart.total,body,"src/lib/cart.ts:32",8
17
17
  refs[20]{symbol,file,line,context}:
18
18
  formatPrice,src/components/Counter.tsx,2,"import { formatPrice } from '../lib';"
19
19
  formatPrice,src/components/Counter.tsx,12,"{cups} cups = {formatPrice(cups * CUP_CENTS)}"
@@ -25,17 +25,18 @@ refs[20]{symbol,file,line,context}:
25
25
  blind_spots[1]{symbol,file,line,reason}:
26
26
  Cart.total,src/components/CartSummary.tsx,16,string key 'total' on a type without that member
27
27
  elapsed_ms: 679
28
- help[5]:
28
+ help[6]:
29
29
  - "Run `ts-axi refs src/lib/format.ts:5` for every Reference of formatPrice"
30
30
  - "Run `ts-axi callers src/lib/format.ts:5` for its call sites with the enclosing function"
31
31
  - "Run `ts-axi def src/lib/format.ts:5` for the changed declaration"
32
+ - Run `ts-axi impact main...HEAD --fields files,tests` for how many files and Test files reference each Symbol
32
33
  - Run `ts-axi impact main...HEAD --grep` for textual matches the checker cannot see
33
34
  - Run `ts-axi impact main...HEAD --depth 2` for who calls the callers
34
35
  ```
35
36
 
36
- (`...` marks rows cut from this README; the command prints all 20.)
37
+ (`...` marks rows cut from this README; the command prints all 20. `--fields all` restores the full row: `{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}`.)
37
38
 
38
- Read-only: no lint, no refactor, no rename.
39
+ Read-only on your code: no lint, no refactor, no rename. The only files it writes are `.ts-axi.json` (`config set`) and, when you ask for it, your agents' hook config (`setup hooks`).
39
40
 
40
41
  ## Status (v0.1.4)
41
42
 
@@ -63,12 +64,15 @@ Then tell your agent about it - one line in `CLAUDE.md` / `AGENTS.md`:
63
64
  Use `ts-axi` to find definitions, references and callers in TypeScript/JavaScript code, and `ts-axi impact` to see what a diff affects before reviewing it.
64
65
  ```
65
66
 
66
- Or install the on-demand Agent Skill (no per-session cost):
67
+ Or install one of the two integrations (you only need one):
67
68
 
68
69
  ```sh
69
- npx skills add cmosgh/ts-axi --skill ts-axi
70
+ ts-axi setup hooks # SessionStart hook: the project overview as ambient context (Claude Code, Codex, OpenCode)
71
+ npx skills add cmosgh/ts-axi --skill ts-axi # on-demand Agent Skill, no per-session cost
70
72
  ```
71
73
 
74
+ The hook runs `ts-axi` with no arguments: it reads only the file tree and git status (no compiler run, no network) and exits 0 in any directory. `ts-axi setup hooks --project` installs it for the current repository only, `ts-axi setup status` shows what is installed, and `ts-axi setup uninstall` removes only ts-axi's own entries.
75
+
72
76
  ## Commands
73
77
 
74
78
  | Command | What it does |
@@ -77,9 +81,10 @@ npx skills add cmosgh/ts-axi --skill ts-axi
77
81
  | `def <target>` | Declarations of a symbol by name (`save`, `User.save`, `path:line`): one match prints detail and body, several print a compact list, none prints `did_you_mean` |
78
82
  | `refs <target>` | References of one symbol, grouped by file, with test counts. A class member that implements an interface (or overrides a base) member counts only its direct References; calls through the interface are `via_interface: n (Iface.member)`, sibling implementations are dropped, and an interface member reports `implementations: n`. `--limit`, `--kind calls`, `--via` (via rows with a `via` column), `--implementations` |
79
83
  | `callers <target>` | Incoming calls of a function-like symbol, split the same way (`via_interface`, `--via`). `--depth 1..3` |
80
- | `outline <file>` | Top-level declarations in source order, class members nested. `--exports` |
81
- | `impact [<git-range> \| -]` | Declarations changed by the working tree (untracked files included; `--staged`), a git range (`main...HEAD`) or a unified diff on stdin (`bb-axi pr diff 42 --raw \| ts-axi impact -`), riskiest first: change class (`signature`, `removed`, `type`, `added`, `body`, `modified`), References outside the change set (direct only), a `via` count of calls through an interface or base member (`--via` lists them), test-file counts, `notes` (a signature change of a member that implements an interface member), `blind_spots`, `local_only`, `file_level` and `skipped` (non-TS files in the diff). `--limit`, `--depth 2..3`, `--grep`. Analyses the current checkout |
84
+ | `outline <file>` | Top-level declarations in source order, class members nested: `{line,kind,name,signature}` (`{at,kind,name,signature}` over several files). `--exports`, `--fields exported` |
85
+ | `impact [<git-range> \| -]` | Declarations changed by the working tree (untracked files included; `--staged`), a git range (`main...HEAD`) or a unified diff on stdin (`bb-axi pr diff 42 --raw \| ts-axi impact -`), riskiest first: change class (`signature`, `removed`, `type`, `added`, `body`, `modified`), References outside the change set (direct only), a `via` count of calls through an interface or base member (`--via` lists them), test-file counts, `notes` (a signature change of a member that implements an interface member), `blind_spots`, `local_only`, `file_level` and `skipped` (non-TS files in the diff). `--limit`, `--depth 2..3`, `--grep`. Rows default to `{symbol,change,at,refs_outside}`; `--fields kind,exported,refs_total,via,files,tests` (or `all`) adds columns. Analyses the current checkout |
82
86
  | `config` | Show effective settings with their source; `config set <key> <value>` and `config unset <key>` write `.ts-axi.json` |
87
+ | `setup <hooks\|status\|uninstall>` | Opt-in SessionStart hook for Claude Code, Codex and OpenCode that runs the home view at session start. `--project` for the current repository only |
83
88
 
84
89
  `ts-axi <command> --help` prints a concise, complete flag reference. `-P <tsconfig>` selects a project on any command that may analyse types; `--json` prints the decoded payload.
85
90
 
@@ -135,9 +140,9 @@ The line numbers in front of the body are file lines, so an agent can cite or co
135
140
  ```
136
141
  $ ts-axi def parse
137
142
  symbol: parse
138
- matches[2]{file,line,kind,exported,signature}:
139
- src/lib/parse.ts,1,function,true,"export function parse(input: string): number"
140
- src/lib/query.ts,1,function,true,"export function parse(q: string): Record<string, string>"
143
+ matches[2]{at,kind,signature}:
144
+ "src/lib/parse.ts:1",function,"export function parse(input: string): number"
145
+ "src/lib/query.ts:1",function,"export function parse(q: string): Record<string, string>"
141
146
  help[4]:
142
147
  - "Run `ts-axi def src/lib/parse.ts:1` for the one in src/lib/parse.ts"
143
148
  - "Run `ts-axi def src/lib/query.ts:1` for the one in src/lib/query.ts"
@@ -167,7 +172,7 @@ refs[1]{file,line,context}:
167
172
  ### Script-style JavaScript
168
173
 
169
174
  ```
170
- $ ts-axi outline client/app.js
175
+ $ ts-axi outline client/app.js --fields exported
171
176
  decls[4]{line,kind,name,exported,signature}:
172
177
  6,function,CartCtrl,true,"controller CartCtrl($scope, priceService)"
173
178
  12,function,priceService,true,service priceService()
package/dist/src/args.js CHANGED
@@ -171,14 +171,17 @@ export function parseFields(raw, available) {
171
171
  const requested = [
172
172
  ...new Set(raw
173
173
  .split(",")
174
- .map((f) => f.trim())
174
+ .map((f) => f.trim().toLowerCase())
175
175
  .filter(Boolean)),
176
176
  ];
177
- const bad = requested.filter((f) => !available.includes(f));
177
+ const bad = requested.filter((f) => f !== "all" && !available.includes(f));
178
178
  if (bad.length > 0) {
179
179
  throw usageError(`unknown field${bad.length > 1 ? "s" : ""}: ${bad.join(", ")}`, [
180
- `available --fields: ${available.join(", ")}`,
180
+ `available --fields: ${available.join(", ")} (or all)`,
181
181
  ]);
182
182
  }
183
- return requested;
183
+ if (requested.includes("all"))
184
+ return [...available];
185
+ // Columns keep the documented order whatever order they were asked in.
186
+ return available.filter((f) => requested.includes(f));
184
187
  }
package/dist/src/cli.js CHANGED
@@ -5,13 +5,14 @@ import { homeCommand } from "./commands/home.js";
5
5
  import { impactCommand, IMPACT_HELP } from "./commands/impact.js";
6
6
  import { outlineCommand, OUTLINE_HELP } from "./commands/outline.js";
7
7
  import { callersCommand, CALLERS_HELP, refsCommand, REFS_HELP } from "./commands/refs.js";
8
+ import { setupCommand, SETUP_HELP } from "./commands/setup.js";
8
9
  import { AxiError, exitCodeForError } from "./errors.js";
9
10
  import { renderErrorOutput, setJsonMode, toJson } from "./render.js";
10
11
  import { VERSION } from "./version.js";
11
12
  export const DESCRIPTION = "TypeScript/JavaScript symbol CLI for agents - definitions, references, callers, file outlines and change impact (what a diff affects) from the TypeScript compiler. Prefer it over grep for 'who uses this'.";
12
13
  export const TOP_HELP = `usage: ts-axi [command] [args] [flags]
13
- commands[7]:
14
- (none)=project overview, def, refs, callers, outline, impact, config
14
+ commands[8]:
15
+ (none)=project overview, def, refs, callers, outline, impact, config, setup (opt-in session hooks)
15
16
  targets:
16
17
  name (save), member (User.save) or position (src/a.ts:42 or src/a.ts:42:7)
17
18
  flags[4]:
@@ -32,6 +33,7 @@ const COMMAND_HELP = {
32
33
  outline: OUTLINE_HELP,
33
34
  impact: IMPACT_HELP,
34
35
  config: CONFIG_HELP,
36
+ setup: SETUP_HELP,
35
37
  };
36
38
  function command(name, handler, ctx) {
37
39
  return async (args) => (args.includes("-h") ? (COMMAND_HELP[name] ?? "").trimEnd() : handler(args, ctx));
@@ -70,10 +72,11 @@ export async function main(options = {}) {
70
72
  outline: command("outline", outlineCommand, ctx),
71
73
  impact: command("impact", impactCommand, ctx),
72
74
  config: command("config", configCommand, ctx),
75
+ setup: command("setup", (args) => setupCommand(args), ctx),
73
76
  },
74
77
  getCommandHelp: (name) => COMMAND_HELP[name],
75
78
  renderUnknownCommand: (name) => `${renderErrorOutput(`unknown command '${name}'`, "VALIDATION_ERROR", [
76
- "commands: def, refs, callers, outline, impact, config (no command = project overview)",
79
+ "commands: def, refs, callers, outline, impact, config, setup (no command = project overview)",
77
80
  "Run `ts-axi --help` for examples",
78
81
  ])}\n`,
79
82
  formatError,
@@ -100,11 +100,10 @@ export function positionOf(group) {
100
100
  export const MATCH_ROWS = 20;
101
101
  /** `matches` (first MATCH_ROWS, shallowest first) plus `matches_total` / `truncated` past that. */
102
102
  export function matchRows(groups) {
103
+ // `at` pastes into `ts-axi def`; TS signatures carry `export`, and `--exported` narrows to exported ones.
103
104
  const rows = groups.slice(0, MATCH_ROWS).map((g) => ({
104
- file: g.primary.file,
105
- line: g.decls[0].nameLine,
105
+ at: positionOf(g),
106
106
  kind: g.primary.kind,
107
- exported: g.primary.exported,
108
107
  signature: g.decls[0].signature,
109
108
  }));
110
109
  return { matches: rows, ...(groups.length > MATCH_ROWS ? { matches_total: groups.length, truncated: true } : {}) };
@@ -8,7 +8,7 @@ flags[7]:
8
8
  --kind <function|class|method|property|variable|interface|type|enum|enum-member|namespace|state>, --in <dir|glob> (repeatable; restrict to files), --exported, --fuzzy (substring, case-insensitive), --full (whole body; default first ${BODY_LINES} lines), -P <tsconfig>, --json
9
9
  output:
10
10
  1 match = file, line, kind, exported, signature + body fenced with file line numbers
11
- several = matches[n]{file,line,kind,exported,signature} (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms (--in, --kind, Class.member, --exported) with how many each keeps
11
+ several = matches[n]{at,kind,signature} (at = path:line) (at most ${MATCH_ROWS}; matches_total + truncated past that) + the narrowing forms (--in, --kind, Class.member, --exported) with how many each keeps
12
12
  none = matches: 0 + did_you_mean
13
13
  examples[5]:
14
14
  ts-axi def User
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { dirname, join, relative, resolve, sep } from "node:path";
3
3
  import { expectPositionals, flag, flags, parseArgs } from "../args.js";
4
- import { usageError } from "../errors.js";
4
+ import { AxiError, usageError } from "../errors.js";
5
5
  import { currentBranch, dirtyFileCount, isGitRepo } from "../git.js";
6
6
  import { block, helpBlock, out } from "../render.js";
7
7
  import { SETTINGS_FILE, isTestFile, loadSettings, readSettingsFile } from "../settings.js";
@@ -95,11 +95,30 @@ export async function homeCommand(args, ctx) {
95
95
  expectPositionals(parsed, 0, "ts-axi [-P <tsconfig>] [--test-glob <glob>]");
96
96
  const cwd = ctx?.cwd ?? process.cwd();
97
97
  const project = flag(parsed, "--project");
98
- const settings = loadSettings(cwd, {
99
- testGlobs: flags(parsed, "--test-glob"),
100
- ...(project !== undefined ? { project } : {}),
101
- });
102
- const tsconfig = resolveTsconfig(cwd, settings);
98
+ const overrides = { testGlobs: flags(parsed, "--test-glob"), ...(project !== undefined ? { project } : {}) };
99
+ // A broken .ts-axi.json must not fail the session-start view: report it and carry on without the file.
100
+ let broken;
101
+ let settings;
102
+ try {
103
+ settings = loadSettings(cwd, overrides);
104
+ }
105
+ catch (error) {
106
+ if (!(error instanceof AxiError))
107
+ throw error;
108
+ broken = error.message;
109
+ settings = loadSettings(cwd, { ...overrides, ignoreFile: true });
110
+ }
111
+ let tsconfig;
112
+ try {
113
+ tsconfig = resolveTsconfig(cwd, settings);
114
+ }
115
+ catch (error) {
116
+ if (!(error instanceof AxiError) || settings.sources.project !== SETTINGS_FILE)
117
+ throw error;
118
+ broken = error.message;
119
+ settings = loadSettings(cwd, { ...overrides, ignoreFile: true });
120
+ tsconfig = resolveTsconfig(cwd, settings);
121
+ }
103
122
  // Same rule as the semantic commands: no explicit project + a multi-project workspace = workspace scope.
104
123
  let workspace;
105
124
  if (settings.project === undefined && (tsconfig === undefined || hasMultiProjectSignal(settings.root))) {
@@ -121,13 +140,17 @@ export async function homeCommand(args, ctx) {
121
140
  ]);
122
141
  const testFiles = counted.files.filter((f) => isTestFile(rel(settings.root, f), settings)).length;
123
142
  const plus = counted.capped ? "+" : "";
124
- const present = readSettingsFile(settings.root) !== undefined;
143
+ const present = broken === undefined && readSettingsFile(settings.root) !== undefined;
125
144
  const onDefaultBranch = git.branch === undefined || git.branch === "main" || git.branch === "master";
126
145
  const help = [];
127
- if (git.dirty > 0 || !onDefaultBranch) {
146
+ if (broken !== undefined)
147
+ help.push(`Fix or delete ${SETTINGS_FILE} - def, refs, callers, impact and config refuse to run until then (outline still works)`);
148
+ if (broken === undefined && (git.dirty > 0 || !onDefaultBranch)) {
128
149
  help.push("Run `ts-axi impact` to see which other code your changes affect");
129
150
  }
130
- help.push("Run `ts-axi outline <file>` to list the declarations in a file", "Run `ts-axi def <name>` to find where a symbol is declared", "Run `ts-axi config` to see or change the test-file globs", "Run `ts-axi --help` for all commands");
151
+ help.push("Run `ts-axi outline <file>` to list the declarations in a file", ...(broken === undefined
152
+ ? ["Run `ts-axi def <name>` to find where a symbol is declared", "Run `ts-axi config` to see or change the test-file globs"]
153
+ : []), "Run `ts-axi --help` for all commands");
131
154
  return out(block({
132
155
  root: settings.root,
133
156
  ...(workspace ? { workspace: `${workspace.kind} (${workspace.projects.length} projects)` } : {}),
@@ -140,6 +163,6 @@ export async function homeCommand(args, ctx) {
140
163
  test_globs: settings.testGlobs.length,
141
164
  test_files: counted.capped ? `${testFiles}${plus}` : testFiles,
142
165
  git: git.text,
143
- settings: `${SETTINGS_FILE} (${present ? "present" : "absent"})`,
166
+ settings: broken !== undefined ? `${SETTINGS_FILE} (ignored - ${/not valid JSON/.test(broken) ? "not valid JSON" : broken.replace(`${SETTINGS_FILE} `, "")})` : `${SETTINGS_FILE} (${present ? "present" : "absent"})`,
144
167
  }), helpBlock(help));
145
168
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, sep } from "node:path";
3
3
  import ts from "typescript";
4
- import { bool, intFlag, parseArgs, flag } from "../args.js";
4
+ import { bool, flag, intFlag, parseArgs, parseFields } from "../args.js";
5
5
  import { changedRanges, parseUnifiedDiff } from "../diff.js";
6
6
  import { AxiError, usageError } from "../errors.js";
7
7
  import { diffNameStatus, diffUnified, isGitRepo, resolveBaseRev, showFile, untrackedFiles } from "../git.js";
@@ -24,10 +24,10 @@ input:
24
24
  (none) = working tree vs HEAD (staged + unstaged + untracked); --staged = index vs HEAD
25
25
  <git-range> = any git diff range (main...HEAD, abc123, v1..v2); - = unified diff on stdin (needs the after side checked out)
26
26
  analysis is of the current checkout - for a range whose head is not the working tree, check that head out first
27
- flags[7]:
28
- --staged, --via (adds References through an interface or base member to refs[n] with a via column), --depth <1-3> (callers_depthN[n]{file,count} for N > 1), --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} ref rows across the report), --grep (adds unresolved[n]{file,line,context} - textual matches that are not References; also searches .astro/.vue/.svelte/.mdx/.html), --test-glob <glob> (repeatable), -P <tsconfig>, --json
27
+ flags[8]:
28
+ --staged, --via (adds References through an interface or base member to refs[n] with a via column), --depth <1-3> (callers_depthN[n]{file,count} for N > 1), --limit <1-${MAX_LIMIT}> (default ${DEFAULT_LIMIT} ref rows across the report), --grep (adds unresolved[n]{file,line,context} - textual matches that are not References; also searches .astro/.vue/.svelte/.mdx/.html), --test-glob <glob> (repeatable), --fields <kind,exported,refs_total,via,files,tests|all> (extra changed[n] columns), -P <tsconfig>, --json
29
29
  output:
30
- base, head, changes (count), changed[n]{symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}
30
+ base, head, changes (count), changed[n]{symbol,change,at,refs_outside} by default; --fields adds columns in place, --fields all gives {symbol,kind,change,exported,at,refs_total,refs_outside,via,files,tests}
31
31
  change = signature | removed | type | added | body | modified (before unavailable) - riskiest first
32
32
  textual = column added only when an untyped (JavaScript; implicit project) Symbol has more whole-word matches outside the change set than refs_outside - those are uses the checker cannot resolve (declaring file only for a non-exported local); - for typed rows; such a Change is never local_only
33
33
  refs_* / files / tests count direct References only; via = References outside the change set that reach the Symbol through an interface or base member it implements (or name an override of it) - they MAY call it
@@ -38,7 +38,7 @@ output:
38
38
  templates = column added only when a changed Symbol is one templates name (directive, component, binding, controller, filter): its usages in .html/.htm/.ejs/.hbs/.njk/.pug files; - for other rows; such a Change is never local_only
39
39
  template_changes[n]{file,symbols} = templates in the diff that use a changed directive, binding, controller or filter - review them with that change
40
40
  skipped[n]{file,reason} = files in the diff that were not analysed (.astro, .json, outside the Project)
41
- tests: 0 on an exported signature or body change = no Test file references it
41
+ tests: 0 on an exported signature or body change = no Test file references it (columns from --fields exported,tests)
42
42
  scope = in a monorepo without -P the program is the touched files + two levels of importers (scope_partial: true when capped)
43
43
  examples[5]:
44
44
  ts-axi impact
@@ -54,8 +54,12 @@ const SPECS = [
54
54
  { name: "--limit" },
55
55
  { name: "--grep", boolean: true },
56
56
  { name: "--test-glob", repeatable: true },
57
+ { name: "--fields" },
57
58
  { name: "-P" },
58
59
  ];
60
+ // Opt-in columns of changed[n]; the default keeps symbol, change, at, refs_outside.
61
+ const IMPACT_FIELDS = ["kind", "exported", "refs_total", "via", "files", "tests"];
62
+ const OPT_IN = new Set(IMPACT_FIELDS);
59
63
  async function readStdin() {
60
64
  if (process.stdin.isTTY) {
61
65
  throw usageError("`impact -` reads a unified diff from stdin and nothing was piped", ["git diff -U0 | ts-axi impact -"]);
@@ -383,15 +387,33 @@ function capped(key, rows) {
383
387
  ...(rows.length > SIDE_ROWS ? { [`${key}_total`]: rows.length } : {}),
384
388
  };
385
389
  }
390
+ /** Quotes a value for a pasteable command. */
391
+ function shellWord(value) {
392
+ return /^[\w@%+=:,.\/-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
393
+ }
394
+ /** The same `impact` invocation with every given flag replayed, the one `extra` sets replaced by it. */
386
395
  function rerun(arg, parsed, extra) {
387
396
  const parts = ["ts-axi impact"];
388
397
  if (arg !== undefined)
389
- parts.push(arg);
390
- if (bool(parsed, "--staged"))
391
- parts.push("--staged");
392
- const project = flag(parsed, "-P");
393
- if (project)
394
- parts.push(`-P ${project}`);
398
+ parts.push(shellWord(arg));
399
+ const replaced = extra.split(" ")[0];
400
+ for (const spec of SPECS) {
401
+ if (spec.name === replaced)
402
+ continue;
403
+ if ("boolean" in spec) {
404
+ if (bool(parsed, spec.name))
405
+ parts.push(spec.name);
406
+ }
407
+ else if (spec.name === "--fields") {
408
+ const fields = parseFields(flag(parsed, "--fields"), IMPACT_FIELDS);
409
+ if (fields.length > 0)
410
+ parts.push(`--fields ${fields.length === IMPACT_FIELDS.length ? "all" : fields.join(",")}`);
411
+ }
412
+ else {
413
+ for (const value of parsed.values.get(spec.name) ?? [])
414
+ parts.push(`${spec.name} ${shellWord(value)}`);
415
+ }
416
+ }
395
417
  parts.push(extra);
396
418
  return `${arg === "-" ? "<diff> | " : ""}${parts.join(" ")}`;
397
419
  }
@@ -434,6 +456,7 @@ export async function impactCommand(args, ctx) {
434
456
  const staged = bool(parsed, "--staged");
435
457
  if (staged && arg !== undefined)
436
458
  throw usageError("--staged cannot be combined with a range or -", ["Run `ts-axi impact --staged` alone"]);
459
+ const extras = new Set(parseFields(flag(parsed, "--fields"), IMPACT_FIELDS));
437
460
  const limit = intFlag(parsed, "--limit", DEFAULT_LIMIT, { min: 1, max: MAX_LIMIT });
438
461
  const depth = intFlag(parsed, "--depth", 1, { min: 1, max: 3 });
439
462
  const grep = bool(parsed, "--grep");
@@ -663,6 +686,8 @@ export async function impactCommand(args, ctx) {
663
686
  help.push(`Run \`ts-axi callers ${top.at}\` for its call sites with the enclosing function`);
664
687
  help.push(`Run \`ts-axi def ${top.at}\` for the changed declaration`);
665
688
  }
689
+ if (listed.length > 0 && extras.size === 0)
690
+ help.push(`Run \`${rerun(arg, parsed, "--fields files,tests")}\` for how many files and Test files reference each Symbol`);
666
691
  const removedTop = listed.find((r) => r.change.change === "removed");
667
692
  if (removedTop)
668
693
  help.push(`${removedTop.symbol} was removed but is still referenced - those files break`);
@@ -697,19 +722,23 @@ export async function impactCommand(args, ctx) {
697
722
  ...header,
698
723
  ...(listed.length > 0
699
724
  ? {
700
- changed: listed.map((r) => ({
701
- symbol: r.symbol,
702
- kind: r.kind,
703
- change: r.change.change,
704
- exported: r.exported,
705
- at: r.at,
706
- refs_total: r.all.length,
707
- refs_outside: r.outside.length,
708
- via: r.via.length,
709
- ...(gap > 0 ? { textual: r.textual ? r.textual.matches : "-" } : {}),
710
- ...(rows.some((x) => x.template) ? { templates: r.templates ? r.templates.length : "-" } : {}),
711
- ...fileCounts(r),
712
- })),
725
+ changed: listed.map((r) => {
726
+ // Built in the full column order, then cut to the default plus --fields, so --fields all matches the old output.
727
+ const full = {
728
+ symbol: r.symbol,
729
+ kind: r.kind,
730
+ change: r.change.change,
731
+ exported: r.exported,
732
+ at: r.at,
733
+ refs_total: r.all.length,
734
+ refs_outside: r.outside.length,
735
+ via: r.via.length,
736
+ ...(gap > 0 ? { textual: r.textual ? r.textual.matches : "-" } : {}),
737
+ ...(rows.some((x) => x.template) ? { templates: r.templates ? r.templates.length : "-" } : {}),
738
+ ...fileCounts(r),
739
+ };
740
+ return Object.fromEntries(Object.entries(full).filter(([k]) => !OPT_IN.has(k) || extras.has(k)));
741
+ }),
713
742
  ...(refRows.length > 0 ? { refs: refRows } : {}),
714
743
  }
715
744
  : {}),
@@ -1,16 +1,16 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { posix, relative, resolve, sep } from "node:path";
3
- import { bool, flag, parseArgs } from "../args.js";
3
+ import { bool, flag, parseArgs, parseFields } from "../args.js";
4
4
  import { usageError } from "../errors.js";
5
5
  import { hasGlobMagic, matchesGlob } from "../glob.js";
6
6
  import { createSyntacticIndex, findRepoRoot, openProject, SOURCE_EXTENSIONS } from "../project.js";
7
7
  import { block, helpBlock, out } from "../render.js";
8
8
  const MAX_ROWS = 300;
9
9
  export const OUTLINE_HELP = `usage: ts-axi outline <file | glob>... [flags]
10
- flags[3]:
11
- --exported (alias --exports; exported Declarations only), -P <tsconfig> (file list for globs), --json
10
+ flags[4]:
11
+ --exported (alias --exports; exported Declarations only), --fields exported (adds an exported column), -P <tsconfig> (file list for globs), --json
12
12
  output:
13
- one file = file + decls[n]{line,kind,name,exported,signature}; several = decls[n]{file,line,kind,name,exported,signature}
13
+ one file = file + decls[n]{line,kind,name,signature}; several = decls[n]{at,kind,name,signature} (at = path:line); --exported keeps only exported Declarations, --fields exported adds the column
14
14
  class members follow their class as Class.member; at most ${MAX_ROWS} rows
15
15
  kind state = a ui-router / ngRoute state as states.<name> (routes.<path>); its callbacks follow as states.<name>.resolve.<key> / states.<name>.onEnter
16
16
  examples[3]:
@@ -25,12 +25,14 @@ function isSource(path) {
25
25
  export async function outlineCommand(args, ctx) {
26
26
  const parsed = parseArgs(args, [
27
27
  { name: "--exported", alias: "--exports", boolean: true },
28
+ { name: "--fields" },
28
29
  { name: "-P" },
29
30
  ], COMMAND);
30
31
  if (parsed.positionals.length === 0) {
31
32
  throw usageError("missing argument", [`usage: ${COMMAND} <file | glob>... [--exported]`]);
32
33
  }
33
34
  const exportedOnly = bool(parsed, "--exported");
35
+ const showExported = parseFields(flag(parsed, "--fields"), ["exported"]).length > 0;
34
36
  const override = flag(parsed, "-P");
35
37
  const root = findRepoRoot(ctx.cwd);
36
38
  const cwdPrefix = relative(root, resolve(ctx.cwd)).split(sep).join("/");
@@ -73,11 +75,10 @@ export async function outlineCommand(args, ctx) {
73
75
  const shown = decls.slice(0, MAX_ROWS);
74
76
  const single = unique.length === 1;
75
77
  const rows = shown.map((d) => ({
76
- ...(single ? {} : { file: d.file }),
77
- line: d.nameLine,
78
+ ...(single ? { line: d.nameLine } : { at: `${d.file}:${d.nameLine}` }),
78
79
  kind: d.kind,
79
80
  name: d.qualifiedName,
80
- exported: d.exported,
81
+ ...(showExported ? { exported: d.exported } : {}),
81
82
  signature: d.signature,
82
83
  }));
83
84
  const help = [];
@@ -0,0 +1,97 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { installSessionStartHooks, sessionStartHookStatus, uninstallSessionStartHooks } from "axi-sdk-js";
3
+ import { bool, expectPositionals, parseArgs } from "../args.js";
4
+ import { axiError, usageError } from "../errors.js";
5
+ import { block, helpBlock, out } from "../render.js";
6
+ export const SETUP_HELP = `usage: ts-axi setup <hooks|status|uninstall> [--project]
7
+ Manage the opt-in SessionStart integration that shows the ts-axi project overview to Claude Code, Codex and OpenCode at session start.
8
+ flags:
9
+ --project (install into the current repository's agent config instead of your user config)
10
+ notes:
11
+ The overview only reads the file tree and git status: no compiler run, no network, exit 0 in any directory (outside a TS/JS project it is a few lines)
12
+ Only these hooks are touched; others in the same files are kept
13
+ examples[3]:
14
+ ts-axi setup hooks
15
+ ts-axi setup hooks --project
16
+ ts-axi setup status
17
+ `;
18
+ // distEntrypoints names the built entrypoint explicitly; the SDK also infers it, so `node dist/bin/ts-axi.js setup hooks` installs either way.
19
+ const IDENTITY = { marker: "ts-axi", binaryNames: ["ts-axi"], distEntrypoints: ["dist/bin/ts-axi.js"] };
20
+ export async function setupCommand(args) {
21
+ const [sub, ...rest] = args;
22
+ if (sub !== "hooks" && sub !== "status" && sub !== "uninstall") {
23
+ throw usageError(sub ? `unknown setup action '${sub}'` : "missing setup action", [
24
+ "Run `ts-axi setup hooks` to install, `ts-axi setup status` to inspect, `ts-axi setup uninstall` to remove",
25
+ ]);
26
+ }
27
+ const command = `ts-axi setup ${sub}`;
28
+ const parsed = parseArgs(rest, [{ name: "--project", boolean: true }], command);
29
+ expectPositionals(parsed, 0, `${command} [--project]`);
30
+ const scope = bool(parsed, "--project") ? "project" : "user";
31
+ const flagHint = scope === "project" ? " --project" : "";
32
+ if (sub === "status") {
33
+ const status = sessionStartHookStatus({ ...IDENTITY, scope });
34
+ // Codex runs hooks only with `[features] hooks = true` in the user config.toml.
35
+ const codexOn = status.codex.installed && status.codex.userFeatureEnabled;
36
+ const agents = { claude_code: status.claude.installed, codex: codexOn, opencode: status.opencode.installed };
37
+ const missing = Object.values(agents).some((installed) => !installed);
38
+ return out(block({
39
+ hooks: {
40
+ scope,
41
+ claude_code: agents.claude_code ? "installed" : "not installed",
42
+ codex: codexOn ? "installed" : status.codex.installed ? "installed (hooks feature off)" : "not installed",
43
+ opencode: agents.opencode ? "installed" : "not installed",
44
+ },
45
+ }), helpBlock(missing ? [`Run \`ts-axi setup hooks${flagHint}\` to install the missing ones`] : []));
46
+ }
47
+ if (sub === "uninstall") {
48
+ const errors = [];
49
+ uninstallSessionStartHooks({ ...IDENTITY, scope, onError: (message) => errors.push(message) });
50
+ const left = installedAgents(scope);
51
+ if (errors.length > 0 || left.length > 0) {
52
+ throw axiError(`hooks not fully removed${left.length > 0 ? ` (still installed: ${left.join("; ")})` : ""}`, "SETUP_FAILED", errors);
53
+ }
54
+ return block({ hooks: { scope, status: "removed (or were not installed)" } });
55
+ }
56
+ const before = configSnapshot(scope);
57
+ // The SDK skips exec paths it does not recognise and reports write failures only via onError, so verify by re-reading.
58
+ const errors = [];
59
+ installSessionStartHooks({ ...IDENTITY, scope, onError: (message) => errors.push(message) });
60
+ const installed = installedAgents(scope, true);
61
+ if (installed.length === 0) {
62
+ throw axiError("no hooks were installed", "SETUP_FAILED", errors.length > 0 ? errors : [
63
+ "Hooks are only installed from an installed binary, not npx: run `npm i -g ts-axi`, then `ts-axi setup hooks`",
64
+ ]);
65
+ }
66
+ if (errors.length > 0 || installed.length < 3) {
67
+ throw axiError(`hooks only partly installed (installed: ${installed.join("; ")})`, "SETUP_FAILED", errors);
68
+ }
69
+ if (configSnapshot(scope) === before) {
70
+ return block({ hooks: { scope, status: "already installed (no-op)", integrations: installed.join("; ") } });
71
+ }
72
+ return out(block({ hooks: { scope, status: "installed", integrations: installed.join("; ") } }), helpBlock(["Restart the agent session to get the ts-axi project overview as ambient context"]));
73
+ }
74
+ /** Contents of the agent config files the hooks live in; equal before and after = nothing was written. */
75
+ function configSnapshot(scope) {
76
+ const status = sessionStartHookStatus({ ...IDENTITY, scope });
77
+ return [status.claude.path, status.codex.path, status.codex.userFeaturePath, status.opencode.path]
78
+ .map((path) => {
79
+ try {
80
+ return readFileSync(path, "utf8");
81
+ }
82
+ catch {
83
+ return "";
84
+ }
85
+ })
86
+ .join("\0");
87
+ }
88
+ /** `active`: count Codex only when its hooks feature is on (after an install); uninstall checks the entries alone. */
89
+ function installedAgents(scope, active = false) {
90
+ const status = sessionStartHookStatus({ ...IDENTITY, scope });
91
+ const agents = [
92
+ ["Claude Code", status.claude.installed],
93
+ ["Codex", status.codex.installed && (!active || status.codex.userFeatureEnabled)],
94
+ ["OpenCode", status.opencode.installed],
95
+ ];
96
+ return agents.filter(([, installed]) => installed).map(([agent]) => agent);
97
+ }
@@ -1,9 +1,7 @@
1
1
  import { AxiError, exitCodeForError as sdkExitCode } from "axi-sdk-js";
2
2
  export { AxiError };
3
- /** SDK mapping (2 usage, 1 otherwise) plus `GIT_ERROR` → 3. */
3
+ /** SDK mapping: 2 for usage errors, 1 otherwise (`GIT_ERROR` included). */
4
4
  export function exitCodeForError(error) {
5
- if (error instanceof AxiError && error.code === "GIT_ERROR")
6
- return 3;
7
5
  return sdkExitCode(error);
8
6
  }
9
7
  export function usageError(message, suggestions = []) {
package/dist/src/git.js CHANGED
@@ -42,7 +42,12 @@ function gitError(result, args) {
42
42
  const message = first.replace(/^(fatal|error): /, "");
43
43
  const suggestions = /not a git repository/i.test(first)
44
44
  ? ["Run `ts-axi impact -` with a unified diff on stdin instead"]
45
- : [];
45
+ : /unknown revision|bad revision|invalid object name|no merge base/i.test(first)
46
+ ? [
47
+ "Run `git branch -a` or `git log --oneline -5` to pick an existing revision",
48
+ "Run `ts-axi impact` for uncommitted changes, or `ts-axi impact <base>...HEAD` for a branch",
49
+ ]
50
+ : [];
46
51
  return new AxiError(message, "GIT_ERROR", suggestions);
47
52
  }
48
53
  async function runOrThrow(cwd, args) {
@@ -74,7 +74,7 @@ export function writeSettingsFile(root, data) {
74
74
  }
75
75
  export function loadSettings(cwd, overrides = {}) {
76
76
  const root = findRoot(cwd);
77
- const file = readSettingsFile(root);
77
+ const file = overrides.ignoreFile ? undefined : readSettingsFile(root);
78
78
  const fileGlobs = file?.testGlobs ?? [];
79
79
  // An empty list cannot replace the defaults: that would turn every file into a non-test.
80
80
  const replace = file?.testGlobsReplace === true && fileGlobs.length > 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-axi",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "AXI-compliant TypeScript symbol CLI for agents - definitions, references, callers, file outlines and change impact (blast radius) with token-efficient TOON output",
5
5
  "type": "module",
6
6
  "keywords": [