@trawlme/cli 1.18.0 → 1.18.1

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/dist/index.d.ts CHANGED
@@ -17,4 +17,11 @@ export declare function collectCommandNames(root: Command): string[];
17
17
  export declare function createProgram(): Command;
18
18
  /** True when this module is the process entrypoint (not merely imported by a test). */
19
19
  export declare function isEntryPoint(argv1: string | undefined, moduleUrl: string): boolean;
20
+ /**
21
+ * True when the invocation is a pure `--help`/`--version` query. These must not
22
+ * trigger the skills auto-sync (a filesystem-mutating startup side effect) — a
23
+ * user running `trawl --version` never expects it to rewrite their skills dirs.
24
+ * (#73)
25
+ */
26
+ export declare function isHelpOrVersion(argv: string[]): boolean;
20
27
  export declare function runCli(argv?: string[]): Promise<void>;
package/dist/index.js CHANGED
@@ -62,8 +62,18 @@ export function createProgram() {
62
62
  export function isEntryPoint(argv1, moduleUrl) {
63
63
  return argv1 !== undefined && moduleUrl === pathToFileURL(argv1).href;
64
64
  }
65
+ /**
66
+ * True when the invocation is a pure `--help`/`--version` query. These must not
67
+ * trigger the skills auto-sync (a filesystem-mutating startup side effect) — a
68
+ * user running `trawl --version` never expects it to rewrite their skills dirs.
69
+ * (#73)
70
+ */
71
+ export function isHelpOrVersion(argv) {
72
+ return argv.some((a) => a === '-h' || a === '--help' || a === '-V' || a === '--version');
73
+ }
65
74
  export async function runCli(argv = process.argv) {
66
- autoUpdateInstalledSkills();
75
+ if (!isHelpOrVersion(argv))
76
+ autoUpdateInstalledSkills();
67
77
  initPostHog();
68
78
  const program = createProgram();
69
79
  registerAllowedCommands(collectCommandNames(program));
@@ -6,26 +6,38 @@ export async function promptPassword(prompt) {
6
6
  process.stdin.setRawMode(true);
7
7
  process.stdin.resume();
8
8
  process.stdin.setEncoding('utf8');
9
- const onData = (char) => {
10
- if (char === '\r' || char === '\n') {
11
- process.stdin.setRawMode(false);
12
- process.stdin.pause();
13
- process.stdin.removeListener('data', onData);
14
- process.stderr.write('\n');
15
- resolve(password);
16
- }
17
- else if (char === '\u0003') {
18
- // Ctrl+C
19
- process.stdin.setRawMode(false);
20
- process.stdin.pause();
21
- process.exit(1);
22
- }
23
- else if (char === '\u007F' || char === '\b') {
24
- // Backspace
25
- password = password.slice(0, -1);
26
- }
27
- else {
28
- password += char;
9
+ const restore = () => {
10
+ process.stdin.setRawMode(false);
11
+ process.stdin.pause();
12
+ process.stdin.removeListener('data', onData);
13
+ };
14
+ const onData = (chunk) => {
15
+ // A stdin chunk is NOT a single keystroke. Terminals deliver a paste as
16
+ // one chunk (Windows Terminal always chunk-pastes), and a paste that
17
+ // ends in a newline embeds a literal \r mid-chunk. Iterate per code
18
+ // point so an embedded Enter finalizes the password instead of being
19
+ // appended to it and hanging for a second Enter. (#73)
20
+ for (const char of chunk) {
21
+ if (char === '\r' || char === '\n') {
22
+ restore();
23
+ process.stderr.write('\n');
24
+ resolve(password);
25
+ return; // ignore anything after the first terminator
26
+ }
27
+ if (char === '') {
28
+ // Ctrl+C: restore the terminal and terminate the line before
29
+ // exiting so the shell prompt isn't left mid-line. 130 = 128+SIGINT.
30
+ restore();
31
+ process.stderr.write('\n');
32
+ process.exit(130);
33
+ }
34
+ if (char === '' || char === '\b') {
35
+ // Backspace
36
+ password = password.slice(0, -1);
37
+ }
38
+ else {
39
+ password += char;
40
+ }
29
41
  }
30
42
  };
31
43
  process.stdin.on('data', onData);
@@ -36,8 +48,14 @@ export async function promptPassword(prompt) {
36
48
  // `question` callback never fires; without a `close` handler the
37
49
  // promise hangs forever and the process exits 0 without logging in.
38
50
  // Reject loudly instead. (#68)
51
+ //
52
+ // No `output` is wired to the interface: readline echoes typed input
53
+ // whenever its output is a TTY, so `trawl login < creds` run from a real
54
+ // terminal would print the password. Omitting output (and terminal:false)
55
+ // guarantees the secret is never echoed. The prompt itself was already
56
+ // written to stderr above. (#73)
39
57
  import('readline').then(({ createInterface }) => {
40
- const rl = createInterface({ input: process.stdin, output: process.stdout });
58
+ const rl = createInterface({ input: process.stdin, terminal: false });
41
59
  let answered = false;
42
60
  rl.question('', (answer) => {
43
61
  answered = true;
@@ -5,8 +5,18 @@ export declare function uninstallSkill(name: string, scope: 'user' | 'local'): s
5
5
  export declare function getInstalledVersion(name: string, scope: 'user' | 'local'): string | null;
6
6
  export declare function isSkillInstalled(name: string, scope: 'user' | 'local'): boolean;
7
7
  /**
8
- * Silently re-installs any skill whose installed version doesn't match the bundled one.
9
- * Called on CLI startup to keep skills in sync with the CLI version.
10
- * Never throws — failures are silent so they don't break unrelated commands.
8
+ * Re-installs any CLI-owned skill whose installed version doesn't match the
9
+ * bundled one. Called on CLI startup to keep skills in sync with the CLI
10
+ * version. Never throws — failures are silent so they don't break unrelated
11
+ * commands.
12
+ *
13
+ * Ownership guard (#73): `installSkill` does `rmSync(recursive)` on the target
14
+ * dir, so this MUST only ever touch dirs the CLI itself installed. Proof of
15
+ * ownership is a `.version` marker. A user-created `.claude/skills/<name>` dir
16
+ * that happens to collide with a bundled skill name carries no marker, so it is
17
+ * left untouched instead of being silently deleted + overwritten.
18
+ *
19
+ * Opt-out: `TRAWL_SKILLS_SYNC=0` disables auto-sync entirely (mirrors
20
+ * `TRAWL_TELEMETRY=0`), for users who manage their skills by hand.
11
21
  */
12
22
  export declare function autoUpdateInstalledSkills(): void;
@@ -54,20 +54,39 @@ export function isSkillInstalled(name, scope) {
54
54
  return existsSync(join(getSkillsBase(scope), name));
55
55
  }
56
56
  /**
57
- * Silently re-installs any skill whose installed version doesn't match the bundled one.
58
- * Called on CLI startup to keep skills in sync with the CLI version.
59
- * Never throws — failures are silent so they don't break unrelated commands.
57
+ * Re-installs any CLI-owned skill whose installed version doesn't match the
58
+ * bundled one. Called on CLI startup to keep skills in sync with the CLI
59
+ * version. Never throws — failures are silent so they don't break unrelated
60
+ * commands.
61
+ *
62
+ * Ownership guard (#73): `installSkill` does `rmSync(recursive)` on the target
63
+ * dir, so this MUST only ever touch dirs the CLI itself installed. Proof of
64
+ * ownership is a `.version` marker. A user-created `.claude/skills/<name>` dir
65
+ * that happens to collide with a bundled skill name carries no marker, so it is
66
+ * left untouched instead of being silently deleted + overwritten.
67
+ *
68
+ * Opt-out: `TRAWL_SKILLS_SYNC=0` disables auto-sync entirely (mirrors
69
+ * `TRAWL_TELEMETRY=0`), for users who manage their skills by hand.
60
70
  */
61
71
  export function autoUpdateInstalledSkills() {
72
+ if (process.env['TRAWL_SKILLS_SYNC'] === '0')
73
+ return;
62
74
  try {
63
75
  const bundledVersion = getBundledSkillsVersion();
64
76
  for (const name of listBundledSkills()) {
65
77
  for (const scope of ['user', 'local']) {
66
78
  if (!isSkillInstalled(name, scope))
67
79
  continue;
68
- if (getInstalledVersion(name, scope) === bundledVersion)
80
+ const installed = getInstalledVersion(name, scope);
81
+ // No `.version` marker → not ours → never delete it.
82
+ if (installed === null)
83
+ continue;
84
+ if (installed === bundledVersion)
69
85
  continue;
70
86
  installSkill(name, scope);
87
+ // One honest line so a destructive-looking re-sync is never silent.
88
+ // stderr keeps stdout clean for --json consumers.
89
+ process.stderr.write(`trawl: re-synced skill "${name}" (${scope}) ${installed} → ${bundledVersion}\n`);
71
90
  }
72
91
  }
73
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.18.0",
3
+ "version": "1.18.1",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {