SLOPSHOPPER

house

House rules for every session: no em dash, person-only asks for risky commands, and no weakened tests.

newguardprocesstimer
v0.1.0no licenseupdated 2026-10-26ilianusss/claude-setup/mods/house
A shopper browsing a rack in a slop shop
README

.claude

A Claude Code setup for fullstack and devops work; this repo is ~/.claude itself.

Role in the system

The setup loads two mods: house, kept here, and workflow, a git submodule pointing at claude-workflow, which can also be installed on its own as a plugin.

Prerequisites

  • Claude Code 2.1.308 or later (the mods use an early-access API).
  • git, with access to both repos.
  • rtk, which the Bash hook calls.

Getting started

On a new machine:

git clone --recurse-submodules git@github.com:ilianusss/.claude.git ~/.claude

On a machine that already has a ~/.claude, quit every Claude Code session, then replace the folder and bring back the runtime data (memory, transcripts, history, plugins, themes):

mv ~/.claude ~/.claude.old
git clone --recurse-submodules git@github.com:ilianusss/.claude.git ~/.claude
rsync -a --ignore-existing \
  --exclude /skills/ --exclude /CLAUDE.md --exclude /RTK.md \
  --exclude '/settings.json*' --exclude /dev-mods/ \
  ~/.claude.old/ ~/.claude/

The clones use SSH so changes to the setup can be pushed back; the submodule follows the same protocol. Old skills are left behind on purpose. Keep ~/.claude.old until a week of use shows nothing is missing. Then start a session in a repo and type /workflow: an answer means the mods loaded.

Commands

The daily commands are /workflow and its subcommands, described in docs/workflow.md.

Project structure

CLAUDE.md        instructions for every session
settings.json    permissions, sandbox, the rtk hook, the mods to load
rules/           guidance loaded when Claude touches a matching file
skills/          knowledge Claude loads on demand
agents/          the reviewer, run in a fresh context
mods/house/      every session: em dashes, safety asks, weakened tests
mods/workflow/   the /workflow mod (submodule)
docs/            how it works and how to change it

Everything else in ~/.claude is Claude Code's runtime data, ignored by git.

Documentation

Outside this repo

The swap leaves these untouched:

  • MCP servers, in ~/.claude.json: claude mcp list shows them.
  • Skills and plugins from the claude.ai account and /plugin.
Source 6 files
hooks/register.ts 12 lines
1import type { Register } from 'claude-code'
2
3import { registerIntegrity } from '../src/integrity'
4import { registerSafety } from '../src/safety'
5import { registerTypography } from '../src/typography'
6
7export const register: Register = on => {
8  registerTypography(on)
9  registerSafety(on)
10  registerIntegrity(on)
11}
12
src/integrity.ts 178 lines
1import type { EngineInterface, On, ToolCallResult } from 'claude-code'
2
3import { matchesAny } from './glob'
4
5const DEFAULT_TEST_GLOBS = [
6  '**/*.{test,spec}.*',
7  '**/__tests__/**',
8  '**/tests/**',
9  '**/test/**',
10  '**/spec/**',
11  '**/e2e/**',
12  '**/*_test.go',
13  '**/test_*.py',
14  '**/*_test.py',
15]
16const SNAPSHOT_GLOBS = ['**/__snapshots__/**', '**/*.snap']
17
18const MARKERS: ReadonlyArray<readonly [label: string, pattern: RegExp]> = [
19  ['.only', /\.only\s*\(/g],
20  ['.skip', /(?<!mark|pytest)\.skip\s*\(/g],
21  ['xit/xdescribe/xtest', /\bx(?:it|describe|test)\s*\(/g],
22  ['fit/fdescribe', /\bf(?:it|describe)\s*\(/g],
23  ['a pytest skip or xfail', /@pytest\.mark\.(?:skip|skipif|xfail)\b|\bpytest\.skip\s*\(/g],
24  ['a unittest skip', /@unittest\.skip|\bself\.skipTest\s*\(/g],
25  ['t.Skip', /\bt\.Skip(?:f|Now)?\s*\(/g],
26  ['#[ignore]', /#\[ignore\]/g],
27]
28const ASSERTIONS =
29  /\bexpect\s*\(|\bassert[A-Za-z_]*\b|\bt\.(?:Error|Errorf|Fatal|Fatalf)\s*\(|\brequire\.[A-Z]\w*\s*\(|\.should\b|\b(?:TEST_ASSERT|ASSERT|EXPECT)_\w+/g
30
31const RULE =
32  'The tests rule forbids weakening a test: revert it, or keep it only if the user agreed, and list it in your report with the reason.'
33
34type SessionState = {
35  cwd: string
36  root: string | undefined
37  globs: string[]
38  reported: Set<string>
39}
40
41function count(text: string, pattern: RegExp): number {
42  return text.match(pattern)?.length ?? 0
43}
44
45function lines(text: string): string[] {
46  return text
47    .split('\n')
48    .map(line => line.trim())
49    .filter(line => line.length > 0)
50}
51
52function plural(count: number, noun: string): string {
53  return `${count} ${noun}${count === 1 ? '' : 's'}`
54}
55
56export function weakenings(before: string, after: string): string[] {
57  const found = MARKERS.filter(([, pattern]) => count(after, pattern) > count(before, pattern)).map(
58    ([label]) => `added ${label}`,
59  )
60  const removed = count(before, ASSERTIONS) - count(after, ASSERTIONS)
61  if (removed > 0) return [...found, `removed ${plural(removed, 'assertion')}`]
62  const kept = new Set(lines(after))
63  const changed = lines(before).filter(line => count(line, ASSERTIONS) > 0 && !kept.has(line)).length
64  return changed > 0 ? [...found, `changed ${plural(changed, 'existing assertion')}`] : found
65}
66
67export function changesByFile(diff: string): Map<string, { removed: string; added: string }> {
68  const files = new Map<string, { removed: string[]; added: string[] }>()
69  let current: { removed: string[]; added: string[] } | undefined
70  let removedFrom: string | undefined
71  for (const line of diff.split('\n')) {
72    if (line.startsWith('--- ')) {
73      removedFrom = line.startsWith('--- a/') ? line.slice(6) : undefined
74    } else if (line.startsWith('+++ ')) {
75      const path = line.startsWith('+++ b/') ? line.slice(6) : removedFrom
76      current = path === undefined ? undefined : { removed: [], added: [] }
77      if (path !== undefined && current !== undefined) files.set(path, current)
78    } else if (current !== undefined && line.startsWith('-')) {
79      current.removed.push(line.slice(1))
80    } else if (current !== undefined && line.startsWith('+')) {
81      current.added.push(line.slice(1))
82    }
83  }
84  return new Map([...files].map(([path, text]) => [path, { removed: text.removed.join('\n'), added: text.added.join('\n') }]))
85}
86
87async function gitRoot($: EngineInterface, cwd: string): Promise<string | undefined> {
88  try {
89    const ran = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
90    return ran.exitCode === 0 ? ran.stdout.trim() : undefined
91  } catch {
92    return undefined
93  }
94}
95
96async function projectTestGlobs($: EngineInterface, base: string): Promise<string[]> {
97  try {
98    const config: unknown = JSON.parse(await $.fs.read(`${base}/.claude/workflow.json`))
99    const globs = typeof config === 'object' && config !== null ? (config as { testGlobs?: unknown }).testGlobs : undefined
100    return Array.isArray(globs) ? globs.filter((glob): glob is string => typeof glob === 'string') : []
101  } catch {
102    return []
103  }
104}
105
106async function readOrEmpty($: EngineInterface, path: string): Promise<string> {
107  try {
108    return await $.fs.read(path)
109  } catch {
110    return ''
111  }
112}
113
114export function registerIntegrity(on: On): void {
115  let session: SessionState = { cwd: '', root: undefined, globs: DEFAULT_TEST_GLOBS, reported: new Set() }
116
117  const relative = (path: string): string => {
118    const base = session.root ?? session.cwd
119    return base !== '' && path.startsWith(`${base}/`) ? path.slice(base.length + 1) : path
120  }
121  const isTest = (path: string): boolean => matchesAny(relative(path), [...session.globs, ...SNAPSHOT_GLOBS])
122
123  const findingsFor = (file: string, before: string, after: string): string[] => {
124    const found = weakenings(before, after)
125    return matchesAny(file, SNAPSHOT_GLOBS) ? [...found, 'changed a snapshot: read every line of the change'] : found
126  }
127
128  const withNote = <T extends string>(ran: ToolCallResult<T>, path: string, before: string, after: string) => {
129    if (ran.deny !== undefined || ran.isError === true || !isTest(path)) return ran
130    const file = relative(path)
131    const findings = findingsFor(file, before, after)
132    if (findings.length === 0) return ran
133    for (const finding of findings) session.reported.add(`${file}: ${finding}`)
134    return { ...ran, context: [...(ran.context ?? []), `house: ${file}: ${findings.join(', ')}. ${RULE}`] }
135  }
136
137  on('session.start', async ($, e, next) => {
138    const started = await next(e)
139    const root = await gitRoot($, e.cwd)
140    session = {
141      cwd: e.cwd,
142      root,
143      globs: [...DEFAULT_TEST_GLOBS, ...(await projectTestGlobs($, root ?? e.cwd))],
144      reported: new Set(),
145    }
146    return started
147  })
148
149  on('tool.call', { tool: 'Edit' }, async ($, e, next) => withNote(await next(e), e.file_path, e.old_string, e.new_string))
150
151  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
152    const before = isTest(e.file_path) ? await readOrEmpty($, e.file_path) : ''
153    return withNote(await next(e), e.file_path, before, e.content)
154  })
155
156  on('turn.complete', async ($, e, next) => {
157    const completed = await next(e)
158    if (e.agentId !== undefined || e.reason !== 'answer' || session.root === undefined) return completed
159    const diff = await $.process.run(['git', 'diff', 'HEAD', '-U0', '--no-color', '--no-ext-diff'], { cwd: session.root })
160    if (diff.exitCode !== 0) return completed
161    const fresh: string[] = []
162    for (const [file, change] of changesByFile(diff.stdout)) {
163      if (!isTest(file)) continue
164      for (const finding of findingsFor(file, change.removed, change.added)) {
165        const key = `${file}: ${finding}`
166        if (session.reported.has(key)) continue
167        session.reported.add(key)
168        fresh.push(key)
169      }
170    }
171    if (fresh.length > 0) {
172      const text = ['house: test files changed through the shell this turn:', ...fresh.map(key => `- ${key}`), RULE].join('\n')
173      $.clock.after(0, () => void $.prompt.submit({ text }))
174    }
175    return completed
176  })
177}
178
src/safety.ts 155 lines
1import type { On } from 'claude-code'
2
3import { segments, tokenize } from './shell'
4
5type Check = (args: readonly string[]) => string | undefined
6
7const WRAPPERS = new Set(['sudo', 'command', 'exec', 'time', 'nohup', 'rtk'])
8const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
9const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
10const KUBE_OPTIONS_WITH_VALUE = new Set(['-n', '--namespace', '--context', '--kube-context', '--kubeconfig', '--cluster', '--user', '-s', '--server'])
11
12const CONTAINED_IN_SANDBOX = new Set(['rm'])
13// The deny rules in settings.json bind a command only inside the sandbox, so outside it a secret path has to ask.
14const SECRET_PATH = /\.env\b|\.ssh\b|\.kube\/config|\.aws\/credentials|\.tfstate|\.pem\b|\.key\b/
15
16const DISCARDS = 'can discard uncommitted work'
17
18function words(segment: string): string[] {
19  const tokens = tokenize(segment.replace(/^[({]\s*/, '').replace(/\s*[)}]$/, ''))
20  let start = 0
21  while (start < tokens.length && (ENV_ASSIGNMENT.test(tokens[start] ?? '') || WRAPPERS.has(tokens[start] ?? ''))) {
22    start += tokens[start] === 'rtk' && tokens[start + 1] === 'proxy' ? 2 : 1
23  }
24  const program = tokens[start]?.split('/').pop()
25  return program === undefined ? [] : [program, ...tokens.slice(start + 1)]
26}
27
28function positional(args: readonly string[], optionsWithValue: ReadonlySet<string>): string[] {
29  const found: string[] = []
30  for (let index = 0; index < args.length; index += 1) {
31    const arg = args[index] ?? ''
32    if (optionsWithValue.has(arg)) index += 1
33    else if (!arg.startsWith('-')) found.push(arg)
34  }
35  return found
36}
37
38function shortFlags(args: readonly string[]): string {
39  return args.filter(arg => /^-[a-zA-Z]+$/.test(arg)).map(arg => arg.slice(1)).join('')
40}
41
42const git: Check = args => {
43  let index = 0
44  while (index < args.length && (args[index] ?? '').startsWith('-')) {
45    index += GIT_OPTIONS_WITH_VALUE.has(args[index] ?? '') ? 2 : 1
46  }
47  const subcommand = args[index]
48  const rest = args.slice(index + 1)
49  const flags = shortFlags(rest)
50  switch (subcommand) {
51    case 'reset':
52      return rest.includes('--hard') ? DISCARDS : undefined
53    case 'clean':
54      return flags.includes('n') || rest.includes('--dry-run') ? undefined : 'deletes untracked files'
55    case 'checkout':
56    case 'switch':
57      return 'switches branches or discards changes'
58    case 'restore': {
59      const onlyStaged = (rest.includes('--staged') || flags.includes('S')) && !rest.includes('--worktree') && !flags.includes('W')
60      return onlyStaged ? undefined : DISCARDS
61    }
62    case 'stash':
63      return rest[0] === 'list' || rest[0] === 'show' ? undefined : 'moves uncommitted work into a stash'
64    case 'branch': {
65      const isDelete = flags.includes('d') || rest.includes('--delete')
66      const isForced = flags.includes('D') || flags.includes('f') || rest.includes('--force')
67      return flags.includes('D') || (isDelete && isForced) ? 'deletes a branch with unmerged work' : undefined
68    }
69    default:
70      return undefined
71  }
72}
73
74const rm: Check = args => {
75  const flags = shortFlags(args)
76  const isRecursive = /[rR]/.test(flags) || args.includes('--recursive')
77  const isForced = flags.includes('f') || args.includes('--force')
78  return isRecursive && isForced ? 'deletes files recursively without asking' : undefined
79}
80
81const terraform: Check = args => {
82  const [subcommand, next] = positional(args, new Set())
83  if (subcommand === 'apply' || subcommand === 'destroy' || subcommand === 'import') return 'changes infrastructure'
84  if (subcommand === 'state' && !['list', 'show', 'pull'].includes(next ?? '')) return 'changes Terraform state'
85  return undefined
86}
87
88const kubectl: Check = args => {
89  const [verb, next] = positional(args, KUBE_OPTIONS_WITH_VALUE)
90  if (['apply', 'delete', 'edit', 'patch', 'scale', 'replace', 'drain'].includes(verb ?? '')) return 'changes a cluster'
91  if (verb === 'rollout' && !['status', 'history'].includes(next ?? '')) return 'changes a cluster'
92  return undefined
93}
94
95const helm: Check = args => {
96  const [subcommand] = positional(args, KUBE_OPTIONS_WITH_VALUE)
97  return ['install', 'upgrade', 'uninstall', 'delete', 'rollback'].includes(subcommand ?? '') ? 'changes a cluster' : undefined
98}
99
100const docker: Check = args => {
101  const [first, second] = positional(args, new Set())
102  if (first === 'system' && second === 'prune') return 'deletes Docker data'
103  if (first === 'volume' && (second === 'rm' || second === 'prune')) return 'deletes Docker volumes'
104  if (first === 'compose' && second === 'down' && (args.includes('-v') || args.includes('--volumes'))) return 'deletes Docker volumes'
105  return undefined
106}
107
108const gh: Check = args => {
109  const [first, second] = positional(args, new Set(['-R', '--repo']))
110  if (first === 'pr' && second === 'merge') return 'merges a pull request'
111  if (first === 'release' && !['list', 'view', 'download'].includes(second ?? '')) return 'changes a release'
112  if (first === 'repo' && second === 'delete') return 'deletes a repository'
113  return undefined
114}
115
116const publish: Check = args => (args.includes('publish') ? 'publishes a package' : undefined)
117
118const CHECKS: Readonly<Record<string, Check>> = {
119  git,
120  rm,
121  terraform,
122  tofu: terraform,
123  kubectl,
124  helm,
125  docker,
126  'docker-compose': args => docker(['compose', ...args]),
127  gh,
128  npm: publish,
129  pnpm: publish,
130  yarn: publish,
131  cargo: publish,
132  dropdb: () => 'drops a database',
133}
134
135export function riskyReason(command: string, unsandboxed = false): string | undefined {
136  for (const segment of segments(command)) {
137    const [program, ...args] = words(segment)
138    const isContained = program !== undefined && !unsandboxed && CONTAINED_IN_SANDBOX.has(program)
139    const what = program === undefined || isContained ? undefined : CHECKS[program]?.(args)
140    if (what !== undefined) return `\`${segment}\` ${what}`
141    if (unsandboxed && SECRET_PATH.test(segment)) return `\`${segment}\` touches a secret outside the sandbox`
142  }
143  return undefined
144}
145
146export function registerSafety(on: On): void {
147  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
148    const verdict = await next(e)
149    if (verdict.decision === 'deny') return verdict
150    const input = (e.input ?? {}) as { command?: unknown; dangerouslyDisableSandbox?: unknown }
151    const reason = riskyReason(typeof input.command === 'string' ? input.command : '', input.dangerouslyDisableSandbox === true)
152    return reason === undefined ? verdict : { decision: 'ask', reason: `house: ${reason}. Approve only if you asked for it.` }
153  }).catch(() => ({ decision: 'ask', reason: 'house: the safety check failed. Approve only if you asked for this command.' }))
154}
155
src/typography.ts 89 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { tokenize } from './shell'
4
5const EM_DASH = String.fromCharCode(0x2014)
6const EM_DASH_WITH_SPACES = new RegExp(`[ \t]*${EM_DASH}[ \t]*`, 'g')
7const WRITES_PROSE = /\b(?:git\s+(?:-\S+\s+)*commit|gh\s+(?:pr|issue|release)\s+(?:create|edit|comment))\b/
8const MESSAGE_FILE_FLAGS = new Set(['-F', '--file', '--body-file'])
9const ADVICE = 'Use a hyphen, comma, colon or parentheses instead (CLAUDE.md, Writing).'
10
11export function countEmDashes(text: string): number {
12  return text.split(EM_DASH).length - 1
13}
14
15export function addedEmDashes(before: string, after: string): number {
16  return Math.max(0, countEmDashes(after) - countEmDashes(before))
17}
18
19export function rewriteEmDashes(text: string): string {
20  return text.replace(EM_DASH_WITH_SPACES, (match, offset: number) => {
21    const end = offset + match.length
22    return end === text.length || text[end] === '\n' ? ' -' : ' - '
23  })
24}
25
26export function writesProse(command: string): boolean {
27  return WRITES_PROSE.test(command)
28}
29
30export function messageFiles(command: string): string[] {
31  const tokens = tokenize(command)
32  const files: string[] = []
33  tokens.forEach((token, index) => {
34    const [flag, inline] = token.split('=', 2)
35    if (flag === undefined || !MESSAGE_FILE_FLAGS.has(flag)) return
36    const file = inline ?? tokens[index + 1]
37    if (file !== undefined && file !== '-') files.push(file)
38  })
39  return files
40}
41
42async function readOrEmpty($: EngineInterface, path: string): Promise<string> {
43  try {
44    return await $.fs.read(path)
45  } catch {
46    return ''
47  }
48}
49
50function refusal(added: number): { deny: string } {
51  return { deny: `house: this adds ${added} em dash${added === 1 ? '' : 'es'}. ${ADVICE}` }
52}
53
54function checkFailed(what: string): { deny: string } {
55  return { deny: `house: the em dash check on ${what} failed, so the call is refused.` }
56}
57
58export function registerTypography(on: On): void {
59  on('tool.call', { tool: 'Edit' }, ($, e, next) => {
60    const added = addedEmDashes(e.old_string, e.new_string)
61    return added > 0 ? refusal(added) : next(e)
62  }).catch(($, e, next) => (next.called ? next(e) : checkFailed('an Edit')))
63
64  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
65    const added = addedEmDashes(await readOrEmpty($, e.file_path), e.content)
66    return added > 0 ? refusal(added) : next(e)
67  }).catch(($, e, next) => (next.called ? next(e) : checkFailed('a Write')))
68
69  on('tool.call', { tool: 'NotebookEdit' }, ($, e, next) => {
70    const added = e.edit_mode === 'delete' ? 0 : countEmDashes(e.new_source)
71    return added > 0 ? refusal(added) : next(e)
72  }).catch(($, e, next) => (next.called ? next(e) : checkFailed('a notebook edit')))
73
74  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
75    if (!writesProse(e.command)) return next(e)
76    let added = countEmDashes(e.command)
77    for (const file of messageFiles(e.command)) added += countEmDashes(await readOrEmpty($, file))
78    return added > 0 ? refusal(added) : next(e)
79  }).catch(($, e, next) => (next.called ? next(e) : checkFailed('a commit or GitHub message')))
80
81  on('session.append', { door: 'response' }, ($, e, next) => {
82    if (e.origin.kind !== 'model') return next(e)
83    const content = e.message.content.map(block =>
84      block.type === 'text' && typeof block.text === 'string' ? { ...block, text: rewriteEmDashes(block.text) } : block,
85    )
86    return next({ ...e, message: { ...e.message, content } })
87  })
88}
89
src/glob.ts 42 lines
1// The workflow mod has its own copy: the two mods ship separately and share no code.
2
3const compiled = new Map<string, RegExp>()
4
5function escape(text: string): string {
6  return text.replace(/[.+^${}()|[\]\\]/g, '\\$&')
7}
8
9function globToRegExp(glob: string): RegExp {
10  let source = ''
11  for (let index = 0; index < glob.length; index += 1) {
12    const char = glob[index] ?? ''
13    if (char === '*' && glob[index + 1] === '*') {
14      const isDirectoryPrefix = glob[index + 2] === '/'
15      source += isDirectoryPrefix ? '(?:.*/)?' : '.*'
16      index += isDirectoryPrefix ? 2 : 1
17    } else if (char === '*') {
18      source += '[^/]*'
19    } else if (char === '?') {
20      source += '[^/]'
21    } else if (char === '{' && glob.indexOf('}', index) > index) {
22      const end = glob.indexOf('}', index)
23      source += `(?:${glob.slice(index + 1, end).split(',').map(escape).join('|')})`
24      index = end
25    } else {
26      source += escape(char)
27    }
28  }
29  return new RegExp(`^${source}$`)
30}
31
32export function matchesAny(path: string, globs: readonly string[]): boolean {
33  return globs.some(glob => {
34    let pattern = compiled.get(glob)
35    if (pattern === undefined) {
36      pattern = globToRegExp(glob)
37      compiled.set(glob, pattern)
38    }
39    return pattern.test(path)
40  })
41}
42
src/shell.ts 64 lines
1const SEPARATORS = ['&&', '||', ';', '|', '&', '\n']
2
3function isQuote(char: string | undefined): char is '"' | "'" {
4  return char === '"' || char === "'"
5}
6
7export function segments(command: string): string[] {
8  const parts: string[] = []
9  let current = ''
10  let quote: string | undefined
11  for (let index = 0; index < command.length; index += 1) {
12    const char = command[index] ?? ''
13    if (quote !== undefined) {
14      current += char
15      if (char === quote) quote = undefined
16      continue
17    }
18    if (isQuote(char)) {
19      quote = char
20      current += char
21      continue
22    }
23    const separator = SEPARATORS.find(candidate => command.startsWith(candidate, index))
24    if (separator !== undefined) {
25      parts.push(current)
26      current = ''
27      index += separator.length - 1
28      continue
29    }
30    current += char
31  }
32  parts.push(current)
33  return parts.map(part => part.trim()).filter(part => part.length > 0)
34}
35
36export function tokenize(segment: string): string[] {
37  const tokens: string[] = []
38  let current = ''
39  let hasToken = false
40  let quote: string | undefined
41  for (const char of segment) {
42    if (quote !== undefined) {
43      if (char === quote) quote = undefined
44      else current += char
45      continue
46    }
47    if (isQuote(char)) {
48      quote = char
49      hasToken = true
50      continue
51    }
52    if (/\s/.test(char)) {
53      if (hasToken) tokens.push(current)
54      current = ''
55      hasToken = false
56      continue
57    }
58    current += char
59    hasToken = true
60  }
61  if (hasToken) tokens.push(current)
62  return tokens
63}
64