diff --git a/src/cli.js b/src/cli.js
--- a/src/cli.js
+++ b/src/cli.js
@@ -1,29 +1,12 @@
 #!/usr/bin/env node
 
-import { lazyPostcss, lazyPostcssImport, lazyCssnano, lazyAutoprefixer } from '../peers/index.js'
-
-import chokidar from 'chokidar'
 import path from 'path'
 import arg from 'arg'
 import fs from 'fs'
-import postcssrc from 'postcss-load-config'
-import { lilconfig } from 'lilconfig'
-import loadPlugins from 'postcss-load-config/src/plugins' // Little bit scary, looking at private/internal API
-import loadOptions from 'postcss-load-config/src/options' // Little bit scary, looking at private/internal API
-import tailwind from './processTailwindFeatures'
-import resolveConfigInternal from '../resolveConfig'
-import fastGlob from 'fast-glob'
-import getModuleDependencies from './lib/getModuleDependencies'
-import log from './util/log'
-import packageJson from '../package.json'
-import normalizePath from 'normalize-path'
-import micromatch from 'micromatch'
-import { validateConfig } from './util/validateConfig.js'
-import { parseCandidateFiles } from './lib/content.js'
 
-let env = {
-  DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0',
-}
+import { build } from './cli/build'
+import { help } from './cli/help'
+import { init } from './cli/init'
 
 function isESM() {
   const pkgPath = path.resolve('./package.json')
@@ -48,112 +31,6 @@ let configs = isESM()
 
 // ---
 
-function indentRecursive(node, indent = 0) {
-  node.each &&
-    node.each((child, i) => {
-      if (!child.raws.before || !child.raws.before.trim() || child.raws.before.includes('\n')) {
-        child.raws.before = `\n${node.type !== 'rule' && i > 0 ? '\n' : ''}${'  '.repeat(indent)}`
-      }
-      child.raws.after = `\n${'  '.repeat(indent)}`
-      indentRecursive(child, indent + 1)
-    })
-}
-
-function formatNodes(root) {
-  indentRecursive(root)
-  if (root.first) {
-    root.first.raws.before = ''
-  }
-}
-
-async function outputFile(file, contents) {
-  if (fs.existsSync(file) && (await fs.promises.readFile(file, 'utf8')) === contents) {
-    return // Skip writing the file
-  }
-
-  // Write the file
-  await fs.promises.writeFile(file, contents, 'utf8')
-}
-
-function drainStdin() {
-  return new Promise((resolve, reject) => {
-    let result = ''
-    process.stdin.on('data', (chunk) => {
-      result += chunk
-    })
-    process.stdin.on('end', () => resolve(result))
-    process.stdin.on('error', (err) => reject(err))
-  })
-}
-
-function help({ message, usage, commands, options }) {
-  let indent = 2
-
-  // Render header
-  console.log()
-  console.log(`${packageJson.name} v${packageJson.version}`)
-
-  // Render message
-  if (message) {
-    console.log()
-    for (let msg of message.split('\n')) {
-      console.log(msg)
-    }
-  }
-
-  // Render usage
-  if (usage && usage.length > 0) {
-    console.log()
-    console.log('Usage:')
-    for (let example of usage) {
-      console.log(' '.repeat(indent), example)
-    }
-  }
-
-  // Render commands
-  if (commands && commands.length > 0) {
-    console.log()
-    console.log('Commands:')
-    for (let command of commands) {
-      console.log(' '.repeat(indent), command)
-    }
-  }
-
-  // Render options
-  if (options) {
-    let groupedOptions = {}
-    for (let [key, value] of Object.entries(options)) {
-      if (typeof value === 'object') {
-        groupedOptions[key] = { ...value, flags: [key] }
-      } else {
-        groupedOptions[value].flags.push(key)
-      }
-    }
-
-    console.log()
-    console.log('Options:')
-    for (let { flags, description, deprecated } of Object.values(groupedOptions)) {
-      if (deprecated) continue
-
-      if (flags.length === 1) {
-        console.log(
-          ' '.repeat(indent + 4 /* 4 = "-i, ".length */),
-          flags.slice().reverse().join(', ').padEnd(20, ' '),
-          description
-        )
-      } else {
-        console.log(
-          ' '.repeat(indent),
-          flags.slice().reverse().join(', ').padEnd(24, ' '),
-          description
-        )
-      }
-    }
-  }
-
-  console.log()
-}
-
 function oneOf(...options) {
   return Object.assign(
     (value = true) => {
@@ -170,15 +47,6 @@ function oneOf(...options) {
   )
 }
 
-function loadPostcss() {
-  // Try to load a local `postcss` version first
-  try {
-    return require('postcss')
-  } catch {}
-
-  return lazyPostcss()
-}
-
 let commands = {
   init: {
     run: init,
@@ -352,675 +220,4 @@ if (args['--help']) {
   process.exit(0)
 }
 
-run()
-
-// ---
-
-function init() {
-  let messages = []
-
-  let tailwindConfigLocation = path.resolve(args['_'][1] ?? `./${configs.tailwind}`)
-  if (fs.existsSync(tailwindConfigLocation)) {
-    messages.push(`${path.basename(tailwindConfigLocation)} already exists.`)
-  } else {
-    let stubFile = fs.readFileSync(
-      args['--full']
-        ? path.resolve(__dirname, '../stubs/defaultConfig.stub.js')
-        : path.resolve(__dirname, '../stubs/simpleConfig.stub.js'),
-      'utf8'
-    )
-
-    // Change colors import
-    stubFile = stubFile.replace('../colors', 'tailwindcss/colors')
-
-    fs.writeFileSync(tailwindConfigLocation, stubFile, 'utf8')
-
-    messages.push(`Created Tailwind CSS config file: ${path.basename(tailwindConfigLocation)}`)
-  }
-
-  if (args['--postcss']) {
-    let postcssConfigLocation = path.resolve(`./${configs.postcss}`)
-    if (fs.existsSync(postcssConfigLocation)) {
-      messages.push(`${path.basename(postcssConfigLocation)} already exists.`)
-    } else {
-      let stubFile = fs.readFileSync(
-        path.resolve(__dirname, '../stubs/defaultPostCssConfig.stub.js'),
-        'utf8'
-      )
-
-      fs.writeFileSync(postcssConfigLocation, stubFile, 'utf8')
-
-      messages.push(`Created PostCSS config file: ${path.basename(postcssConfigLocation)}`)
-    }
-  }
-
-  if (messages.length > 0) {
-    console.log()
-    for (let message of messages) {
-      console.log(message)
-    }
-  }
-}
-
-async function build() {
-  let input = args['--input']
-  let output = args['--output']
-  let shouldWatch = args['--watch']
-  let shouldPoll = args['--poll']
-  let shouldCoalesceWriteEvents = shouldPoll || process.platform === 'win32'
-  let includePostCss = args['--postcss']
-
-  // Polling interval in milliseconds
-  // Used only when polling or coalescing add/change events on Windows
-  let pollInterval = 10
-
-  // TODO: Deprecate this in future versions
-  if (!input && args['_'][1]) {
-    console.error('[deprecation] Running tailwindcss without -i, please provide an input file.')
-    input = args['--input'] = args['_'][1]
-  }
-
-  if (input && input !== '-' && !fs.existsSync((input = path.resolve(input)))) {
-    console.error(`Specified input file ${args['--input']} does not exist.`)
-    process.exit(9)
-  }
-
-  if (args['--config'] && !fs.existsSync((args['--config'] = path.resolve(args['--config'])))) {
-    console.error(`Specified config file ${args['--config']} does not exist.`)
-    process.exit(9)
-  }
-
-  let configPath = args['--config']
-    ? args['--config']
-    : ((defaultPath) => (fs.existsSync(defaultPath) ? defaultPath : null))(
-        path.resolve(`./${configs.tailwind}`)
-      )
-
-  async function loadPostCssPlugins() {
-    let customPostCssPath = typeof args['--postcss'] === 'string' ? args['--postcss'] : undefined
-    let config = customPostCssPath
-      ? await (async () => {
-          let file = path.resolve(customPostCssPath)
-
-          // Implementation, see: https://unpkg.com/browse/postcss-load-config@3.1.0/src/index.js
-          let { config = {} } = await lilconfig('postcss').load(file)
-          if (typeof config === 'function') {
-            config = config()
-          } else {
-            config = Object.assign({}, config)
-          }
-
-          if (!config.plugins) {
-            config.plugins = []
-          }
-
-          return {
-            file,
-            plugins: loadPlugins(config, file),
-            options: loadOptions(config, file),
-          }
-        })()
-      : await postcssrc()
-
-    let configPlugins = config.plugins
-
-    let configPluginTailwindIdx = configPlugins.findIndex((plugin) => {
-      if (typeof plugin === 'function' && plugin.name === 'tailwindcss') {
-        return true
-      }
-
-      if (typeof plugin === 'object' && plugin !== null && plugin.postcssPlugin === 'tailwindcss') {
-        return true
-      }
-
-      return false
-    })
-
-    let beforePlugins =
-      configPluginTailwindIdx === -1 ? [] : configPlugins.slice(0, configPluginTailwindIdx)
-    let afterPlugins =
-      configPluginTailwindIdx === -1
-        ? configPlugins
-        : configPlugins.slice(configPluginTailwindIdx + 1)
-
-    return [beforePlugins, afterPlugins, config.options]
-  }
-
-  function loadBuiltinPostcssPlugins() {
-    let postcss = loadPostcss()
-    let IMPORT_COMMENT = '__TAILWIND_RESTORE_IMPORT__: '
-    return [
-      [
-        (root) => {
-          root.walkAtRules('import', (rule) => {
-            if (rule.params.slice(1).startsWith('tailwindcss/')) {
-              rule.after(postcss.comment({ text: IMPORT_COMMENT + rule.params }))
-              rule.remove()
-            }
-          })
-        },
-        (() => {
-          try {
-            return require('postcss-import')
-          } catch {}
-
-          return lazyPostcssImport()
-        })(),
-        (root) => {
-          root.walkComments((rule) => {
-            if (rule.text.startsWith(IMPORT_COMMENT)) {
-              rule.after(
-                postcss.atRule({
-                  name: 'import',
-                  params: rule.text.replace(IMPORT_COMMENT, ''),
-                })
-              )
-              rule.remove()
-            }
-          })
-        },
-      ],
-      [],
-      {},
-    ]
-  }
-
-  function resolveConfig() {
-    let config = configPath ? require(configPath) : {}
-
-    if (args['--purge']) {
-      log.warn('purge-flag-deprecated', [
-        'The `--purge` flag has been deprecated.',
-        'Please use `--content` instead.',
-      ])
-      if (!args['--content']) {
-        args['--content'] = args['--purge']
-      }
-    }
-
-    if (args['--content']) {
-      let files = args['--content'].split(/(?<!{[^}]+),/)
-      let resolvedConfig = resolveConfigInternal(config, { content: { files } })
-      resolvedConfig.content.files = files
-      resolvedConfig = validateConfig(resolvedConfig)
-      return resolvedConfig
-    }
-
-    let resolvedConfig = resolveConfigInternal(config)
-    resolvedConfig = validateConfig(resolvedConfig)
-    return resolvedConfig
-  }
-
-  function extractFileGlobs(config) {
-    let context = {
-      tailwindConfig: config,
-      userConfigPath: configPath,
-    }
-
-    let contentPaths = parseCandidateFiles(context, config)
-
-    return contentPaths.map((contentPath) => contentPath.pattern)
-  }
-
-  function extractRawContent(config) {
-    return config.content.files.filter((file) => {
-      return typeof file === 'object' && file !== null
-    })
-  }
-
-  function getChangedContent(config) {
-    let changedContent = []
-
-    // Resolve globs from the content config
-    let globs = extractFileGlobs(config)
-    let files = fastGlob.sync(globs)
-
-    for (let file of files) {
-      changedContent.push({
-        content: fs.readFileSync(path.resolve(file), 'utf8'),
-        extension: path.extname(file).slice(1),
-      })
-    }
-
-    // Resolve raw content in the tailwind config
-    for (let { raw: content, extension = 'html' } of extractRawContent(config)) {
-      changedContent.push({ content, extension })
-    }
-
-    return changedContent
-  }
-
-  async function buildOnce() {
-    let config = resolveConfig()
-    let changedContent = getChangedContent(config)
-
-    let tailwindPlugin = () => {
-      return {
-        postcssPlugin: 'tailwindcss',
-        Once(root, { result }) {
-          tailwind(({ createContext }) => {
-            return () => {
-              return createContext(config, changedContent)
-            }
-          })(root, result)
-        },
-      }
-    }
-
-    tailwindPlugin.postcss = true
-
-    let [beforePlugins, afterPlugins, postcssOptions] = includePostCss
-      ? await loadPostCssPlugins()
-      : loadBuiltinPostcssPlugins()
-
-    let plugins = [
-      ...beforePlugins,
-      tailwindPlugin,
-      !args['--minify'] && formatNodes,
-      ...afterPlugins,
-      !args['--no-autoprefixer'] &&
-        (() => {
-          // Try to load a local `autoprefixer` version first
-          try {
-            return require('autoprefixer')
-          } catch {}
-
-          return lazyAutoprefixer()
-        })(),
-      args['--minify'] &&
-        (() => {
-          let options = { preset: ['default', { cssDeclarationSorter: false }] }
-
-          // Try to load a local `cssnano` version first
-          try {
-            return require('cssnano')
-          } catch {}
-
-          return lazyCssnano()(options)
-        })(),
-    ].filter(Boolean)
-
-    let postcss = loadPostcss()
-    let processor = postcss(plugins)
-
-    function processCSS(css) {
-      let start = process.hrtime.bigint()
-      return Promise.resolve()
-        .then(() => (output ? fs.promises.mkdir(path.dirname(output), { recursive: true }) : null))
-        .then(() => processor.process(css, { ...postcssOptions, from: input, to: output }))
-        .then((result) => {
-          if (!output) {
-            return process.stdout.write(result.css)
-          }
-
-          return Promise.all(
-            [
-              outputFile(output, result.css),
-              result.map && outputFile(output + '.map', result.map.toString()),
-            ].filter(Boolean)
-          )
-        })
-        .then(() => {
-          let end = process.hrtime.bigint()
-          console.error()
-          console.error('Done in', (end - start) / BigInt(1e6) + 'ms.')
-        })
-    }
-
-    let css = await (() => {
-      // Piping in data, let's drain the stdin
-      if (input === '-') {
-        return drainStdin()
-      }
-
-      // Input file has been provided
-      if (input) {
-        return fs.readFileSync(path.resolve(input), 'utf8')
-      }
-
-      // No input file provided, fallback to default atrules
-      return '@tailwind base; @tailwind components; @tailwind utilities'
-    })()
-
-    return processCSS(css)
-  }
-
-  let context = null
-
-  async function startWatcher() {
-    let changedContent = []
-    let configDependencies = []
-    let contextDependencies = new Set()
-    let watcher = null
-
-    function refreshConfig() {
-      env.DEBUG && console.time('Module dependencies')
-      for (let file of configDependencies) {
-        delete require.cache[require.resolve(file)]
-      }
-
-      if (configPath) {
-        configDependencies = getModuleDependencies(configPath).map(({ file }) => file)
-
-        for (let dependency of configDependencies) {
-          contextDependencies.add(dependency)
-        }
-      }
-      env.DEBUG && console.timeEnd('Module dependencies')
-
-      return resolveConfig()
-    }
-
-    let [beforePlugins, afterPlugins] = includePostCss
-      ? await loadPostCssPlugins()
-      : loadBuiltinPostcssPlugins()
-
-    let plugins = [
-      ...beforePlugins,
-      '__TAILWIND_PLUGIN_POSITION__',
-      !args['--minify'] && formatNodes,
-      ...afterPlugins,
-      !args['--no-autoprefixer'] &&
-        (() => {
-          // Try to load a local `autoprefixer` version first
-          try {
-            return require('autoprefixer')
-          } catch {}
-
-          return lazyAutoprefixer()
-        })(),
-      args['--minify'] &&
-        (() => {
-          let options = { preset: ['default', { cssDeclarationSorter: false }] }
-
-          // Try to load a local `cssnano` version first
-          try {
-            return require('cssnano')
-          } catch {}
-
-          return lazyCssnano()(options)
-        })(),
-    ].filter(Boolean)
-
-    async function rebuild(config) {
-      env.DEBUG && console.time('Finished in')
-
-      let tailwindPlugin = () => {
-        return {
-          postcssPlugin: 'tailwindcss',
-          Once(root, { result }) {
-            env.DEBUG && console.time('Compiling CSS')
-            tailwind(({ createContext }) => {
-              console.error()
-              console.error('Rebuilding...')
-
-              return () => {
-                if (context !== null) {
-                  context.changedContent = changedContent.splice(0)
-                  return context
-                }
-
-                env.DEBUG && console.time('Creating context')
-                context = createContext(config, changedContent.splice(0))
-                env.DEBUG && console.timeEnd('Creating context')
-                return context
-              }
-            })(root, result)
-            env.DEBUG && console.timeEnd('Compiling CSS')
-          },
-        }
-      }
-
-      tailwindPlugin.postcss = true
-
-      let tailwindPluginIdx = plugins.indexOf('__TAILWIND_PLUGIN_POSITION__')
-      let copy = plugins.slice()
-      copy.splice(tailwindPluginIdx, 1, tailwindPlugin)
-      let postcss = loadPostcss()
-      let processor = postcss(copy)
-
-      function processCSS(css) {
-        let start = process.hrtime.bigint()
-        return Promise.resolve()
-          .then(() =>
-            output ? fs.promises.mkdir(path.dirname(output), { recursive: true }) : null
-          )
-          .then(() => processor.process(css, { from: input, to: output }))
-          .then(async (result) => {
-            for (let message of result.messages) {
-              if (message.type === 'dependency') {
-                contextDependencies.add(message.file)
-              }
-            }
-            watcher.add([...contextDependencies])
-
-            if (!output) {
-              return process.stdout.write(result.css)
-            }
-
-            return Promise.all(
-              [
-                outputFile(output, result.css),
-                result.map && outputFile(output + '.map', result.map.toString()),
-              ].filter(Boolean)
-            )
-          })
-          .then(() => {
-            let end = process.hrtime.bigint()
-            console.error('Done in', (end - start) / BigInt(1e6) + 'ms.')
-          })
-          .catch((err) => {
-            if (err.name === 'CssSyntaxError') {
-              console.error(err.toString())
-            } else {
-              console.error(err)
-            }
-          })
-      }
-
-      let css = await (() => {
-        // Piping in data, let's drain the stdin
-        if (input === '-') {
-          return drainStdin()
-        }
-
-        // Input file has been provided
-        if (input) {
-          return fs.readFileSync(path.resolve(input), 'utf8')
-        }
-
-        // No input file provided, fallback to default atrules
-        return '@tailwind base; @tailwind components; @tailwind utilities'
-      })()
-
-      let result = await processCSS(css)
-      env.DEBUG && console.timeEnd('Finished in')
-      return result
-    }
-
-    let config = refreshConfig(configPath)
-    let contentPatterns = refreshContentPatterns(config)
-
-    /**
-     * @param {import('../types/config.js').RequiredConfig} config
-     * @return {{all: string[], dynamic: string[], static: string[]}}
-     **/
-    function refreshContentPatterns(config) {
-      let globs = extractFileGlobs(config)
-      let tasks = fastGlob.generateTasks(globs, { absolute: true })
-      let dynamicPatterns = tasks.filter((task) => task.dynamic).flatMap((task) => task.patterns)
-      let staticPatterns = tasks.filter((task) => !task.dynamic).flatMap((task) => task.patterns)
-
-      return {
-        all: [...staticPatterns, ...dynamicPatterns],
-        dynamic: dynamicPatterns,
-      }
-    }
-
-    if (input) {
-      contextDependencies.add(path.resolve(input))
-    }
-
-    watcher = chokidar.watch([...contextDependencies, ...extractFileGlobs(config)], {
-      // Force checking for atomic writes in all situations
-      // This causes chokidar to wait up to 100ms for a file to re-added after it's been unlinked
-      // This only works when watching directories though
-      atomic: true,
-
-      usePolling: shouldPoll,
-      interval: shouldPoll ? pollInterval : undefined,
-      ignoreInitial: true,
-      awaitWriteFinish: shouldCoalesceWriteEvents
-        ? {
-            stabilityThreshold: 50,
-            pollInterval: pollInterval,
-          }
-        : false,
-    })
-
-    let chain = Promise.resolve()
-    let pendingRebuilds = new Set()
-
-    watcher.on('change', async (file) => {
-      if (contextDependencies.has(file)) {
-        env.DEBUG && console.time('Resolve config')
-        context = null
-        config = refreshConfig(configPath)
-        contentPatterns = refreshContentPatterns(config)
-        env.DEBUG && console.timeEnd('Resolve config')
-
-        env.DEBUG && console.time('Watch new files')
-        let globs = extractFileGlobs(config)
-        watcher.add(configDependencies)
-        watcher.add(globs)
-        env.DEBUG && console.timeEnd('Watch new files')
-
-        chain = chain.then(async () => {
-          changedContent.push(...getChangedContent(config))
-          await rebuild(config)
-        })
-      } else {
-        chain = chain.then(async () => {
-          changedContent.push({
-            content: fs.readFileSync(path.resolve(file), 'utf8'),
-            extension: path.extname(file).slice(1),
-          })
-
-          await rebuild(config)
-        })
-      }
-    })
-
-    /**
-     * When rapidly saving files atomically a couple of situations can happen:
-     * - The file is missing since the external program has deleted it by the time we've gotten around to reading it from the earlier save.
-     * - The file is being written to by the external program by the time we're going to read it and is thus treated as busy because a lock is held.
-     *
-     * To work around this we retry reading the file a handful of times with a delay between each attempt
-     *
-     * @param {string} path
-     * @param {number} tries
-     * @returns {string}
-     * @throws {Error} If the file is still missing or busy after the specified number of tries
-     */
-    async function readFileWithRetries(path, tries = 5) {
-      for (let n = 0; n <= tries; n++) {
-        try {
-          return await fs.promises.readFile(path, 'utf8')
-        } catch (err) {
-          if (n !== tries) {
-            if (err.code === 'ENOENT' || err.code === 'EBUSY') {
-              await new Promise((resolve) => setTimeout(resolve, 10))
-
-              continue
-            }
-          }
-
-          throw err
-        }
-      }
-    }
-
-    // Restore watching any files that are "removed"
-    // This can happen when a file is pseudo-atomically replaced (a copy is created, overwritten, the old one is unlinked, and the new one is renamed)
-    // TODO: An an optimization we should allow removal when the config changes
-    watcher.on('unlink', (file) => {
-      file = normalizePath(file)
-
-      // Only re-add the file if it's not covered by a dynamic pattern
-      if (!micromatch.some([file], contentPatterns.dynamic)) {
-        watcher.add(file)
-      }
-    })
-
-    // Some applications such as Visual Studio (but not VS Code)
-    // will only fire a rename event for atomic writes and not a change event
-    // This is very likely a chokidar bug but it's one we need to work around
-    // We treat this as a change event and rebuild the CSS
-    watcher.on('raw', (evt, filePath, meta) => {
-      if (evt !== 'rename') {
-        return
-      }
-
-      let watchedPath = meta.watchedPath
-
-      // Watched path might be the file itself
-      // Or the directory it is in
-      filePath = watchedPath.endsWith(filePath) ? watchedPath : path.join(watchedPath, filePath)
-
-      // Skip this event since the files it is for does not match any of the registered content globs
-      if (!micromatch.some([filePath], contentPatterns.all)) {
-        return
-      }
-
-      // Skip since we've already queued a rebuild for this file that hasn't happened yet
-      if (pendingRebuilds.has(filePath)) {
-        return
-      }
-
-      pendingRebuilds.add(filePath)
-
-      chain = chain.then(async () => {
-        let content
-
-        try {
-          content = await readFileWithRetries(path.resolve(filePath))
-        } finally {
-          pendingRebuilds.delete(filePath)
-        }
-
-        changedContent.push({
-          content,
-          extension: path.extname(filePath).slice(1),
-        })
-
-        await rebuild(config)
-      })
-    })
-
-    watcher.on('add', async (file) => {
-      chain = chain.then(async () => {
-        changedContent.push({
-          content: fs.readFileSync(path.resolve(file), 'utf8'),
-          extension: path.extname(file).slice(1),
-        })
-
-        await rebuild(config)
-      })
-    })
-
-    chain = chain.then(() => {
-      changedContent.push(...getChangedContent(config))
-      return rebuild(config)
-    })
-  }
-
-  if (shouldWatch) {
-    /* Abort the watcher if stdin is closed to avoid zombie processes */
-    process.stdin.on('end', () => process.exit(0))
-    process.stdin.resume()
-    startWatcher()
-  } else {
-    buildOnce()
-  }
-}
+run(args, configs)
diff --git a/src/cli/build/deps.js b/src/cli/build/deps.js
new file mode 100644
--- /dev/null
+++ b/src/cli/build/deps.js
@@ -0,0 +1,56 @@
+// @ts-check
+
+import {
+  // @ts-ignore
+  lazyPostcss,
+
+  // @ts-ignore
+  lazyPostcssImport,
+
+  // @ts-ignore
+  lazyCssnano,
+
+  // @ts-ignore
+  lazyAutoprefixer,
+} from '../../../peers/index.js'
+
+/**
+ * @returns {import('postcss')}
+ */
+export function loadPostcss() {
+  // Try to load a local `postcss` version first
+  try {
+    return require('postcss')
+  } catch {}
+
+  return lazyPostcss()
+}
+
+export function loadPostcssImport() {
+  // Try to load a local `postcss-import` version first
+  try {
+    return require('postcss-import')
+  } catch {}
+
+  return lazyPostcssImport()
+}
+
+export function loadCssNano() {
+  let options = { preset: ['default', { cssDeclarationSorter: false }] }
+
+  // Try to load a local `cssnano` version first
+  try {
+    return require('cssnano')
+  } catch {}
+
+  return lazyCssnano()(options)
+}
+
+export function loadAutoprefixer() {
+  // Try to load a local `autoprefixer` version first
+  try {
+    return require('autoprefixer')
+  } catch {}
+
+  return lazyAutoprefixer()
+}
diff --git a/src/cli/build/index.js b/src/cli/build/index.js
new file mode 100644
--- /dev/null
+++ b/src/cli/build/index.js
@@ -0,0 +1,45 @@
+// @ts-check
+
+import fs from 'fs'
+import path from 'path'
+import { createProcessor } from './plugin.js'
+
+export async function build(args, configs) {
+  let input = args['--input']
+  let shouldWatch = args['--watch']
+
+  // TODO: Deprecate this in future versions
+  if (!input && args['_'][1]) {
+    console.error('[deprecation] Running tailwindcss without -i, please provide an input file.')
+    input = args['--input'] = args['_'][1]
+  }
+
+  if (input && input !== '-' && !fs.existsSync((input = path.resolve(input)))) {
+    console.error(`Specified input file ${args['--input']} does not exist.`)
+    process.exit(9)
+  }
+
+  if (args['--config'] && !fs.existsSync((args['--config'] = path.resolve(args['--config'])))) {
+    console.error(`Specified config file ${args['--config']} does not exist.`)
+    process.exit(9)
+  }
+
+  // TODO: Reference the @config path here if exists
+  let configPath = args['--config']
+    ? args['--config']
+    : ((defaultPath) => (fs.existsSync(defaultPath) ? defaultPath : null))(
+        path.resolve(`./${configs.tailwind}`)
+      )
+
+  let processor = await createProcessor(args, configPath)
+
+  if (shouldWatch) {
+    /* Abort the watcher if stdin is closed to avoid zombie processes */
+    process.stdin.on('end', () => process.exit(0))
+    process.stdin.resume()
+
+    await processor.watch()
+  } else {
+    await processor.build()
+  }
+}
diff --git a/src/cli/build/plugin.js b/src/cli/build/plugin.js
new file mode 100644
--- /dev/null
+++ b/src/cli/build/plugin.js
@@ -0,0 +1,372 @@
+// @ts-check
+
+import path from 'path'
+import fs from 'fs'
+import postcssrc from 'postcss-load-config'
+import { lilconfig } from 'lilconfig'
+import loadPlugins from 'postcss-load-config/src/plugins' // Little bit scary, looking at private/internal API
+import loadOptions from 'postcss-load-config/src/options' // Little bit scary, looking at private/internal API
+
+import tailwind from '../../processTailwindFeatures'
+import { loadAutoprefixer, loadCssNano, loadPostcss, loadPostcssImport } from './deps'
+import { formatNodes, drainStdin, outputFile } from './utils'
+import { env } from '../shared'
+import resolveConfig from '../../../resolveConfig.js'
+import getModuleDependencies from '../../lib/getModuleDependencies.js'
+import { parseCandidateFiles } from '../../lib/content.js'
+import { createWatcher } from './watching.js'
+import fastGlob from 'fast-glob'
+import { findAtConfigPath } from '../../lib/findAtConfigPath.js'
+
+/**
+ *
+ * @param {string} [customPostCssPath ]
+ * @returns
+ */
+async function loadPostCssPlugins(customPostCssPath) {
+  let config = customPostCssPath
+    ? await (async () => {
+        let file = path.resolve(customPostCssPath)
+
+        // Implementation, see: https://unpkg.com/browse/postcss-load-config@3.1.0/src/index.js
+        // @ts-ignore
+        let { config = {} } = await lilconfig('postcss').load(file)
+        if (typeof config === 'function') {
+          config = config()
+        } else {
+          config = Object.assign({}, config)
+        }
+
+        if (!config.plugins) {
+          config.plugins = []
+        }
+
+        return {
+          file,
+          plugins: loadPlugins(config, file),
+          options: loadOptions(config, file),
+        }
+      })()
+    : await postcssrc()
+
+  let configPlugins = config.plugins
+
+  let configPluginTailwindIdx = configPlugins.findIndex((plugin) => {
+    if (typeof plugin === 'function' && plugin.name === 'tailwindcss') {
+      return true
+    }
+
+    if (typeof plugin === 'object' && plugin !== null && plugin.postcssPlugin === 'tailwindcss') {
+      return true
+    }
+
+    return false
+  })
+
+  let beforePlugins =
+    configPluginTailwindIdx === -1 ? [] : configPlugins.slice(0, configPluginTailwindIdx)
+  let afterPlugins =
+    configPluginTailwindIdx === -1
+      ? configPlugins
+      : configPlugins.slice(configPluginTailwindIdx + 1)
+
+  return [beforePlugins, afterPlugins, config.options]
+}
+
+function loadBuiltinPostcssPlugins() {
+  let postcss = loadPostcss()
+  let IMPORT_COMMENT = '__TAILWIND_RESTORE_IMPORT__: '
+  return [
+    [
+      (root) => {
+        root.walkAtRules('import', (rule) => {
+          if (rule.params.slice(1).startsWith('tailwindcss/')) {
+            rule.after(postcss.comment({ text: IMPORT_COMMENT + rule.params }))
+            rule.remove()
+          }
+        })
+      },
+      loadPostcssImport(),
+      (root) => {
+        root.walkComments((rule) => {
+          if (rule.text.startsWith(IMPORT_COMMENT)) {
+            rule.after(
+              postcss.atRule({
+                name: 'import',
+                params: rule.text.replace(IMPORT_COMMENT, ''),
+              })
+            )
+            rule.remove()
+          }
+        })
+      },
+    ],
+    [],
+    {},
+  ]
+}
+
+let state = {
+  /** @type {any} */
+  context: null,
+
+  /** @type {ReturnType<typeof createWatcher> | null} */
+  watcher: null,
+
+  /** @type {{content: string, extension: string}[]} */
+  changedContent: [],
+
+  configDependencies: new Set(),
+  contextDependencies: new Set(),
+
+  /** @type {import('../../lib/content.js').ContentPath[]} */
+  contentPaths: [],
+
+  refreshContentPaths() {
+    this.contentPaths = parseCandidateFiles(this.context, this.context?.tailwindConfig)
+  },
+
+  get config() {
+    return this.context.tailwindConfig
+  },
+
+  get contentPatterns() {
+    return {
+      all: this.contentPaths.map((contentPath) => contentPath.pattern),
+      dynamic: this.contentPaths
+        .filter((contentPath) => contentPath.glob !== undefined)
+        .map((contentPath) => contentPath.pattern),
+    }
+  },
+
+  loadConfig(configPath) {
+    if (this.watcher && configPath) {
+      this.refreshConfigDependencies(configPath)
+    }
+
+    let config = configPath ? require(configPath) : {}
+
+    // @ts-ignore
+    config = resolveConfig(config, { content: { files: [] } })
+
+    return config
+  },
+
+  refreshConfigDependencies(configPath) {
+    env.DEBUG && console.time('Module dependencies')
+
+    for (let file of this.configDependencies) {
+      delete require.cache[require.resolve(file)]
+    }
+
+    if (configPath) {
+      let deps = getModuleDependencies(configPath).map(({ file }) => file)
+
+      for (let dependency of deps) {
+        this.configDependencies.add(dependency)
+      }
+    }
+
+    env.DEBUG && console.timeEnd('Module dependencies')
+  },
+
+  readContentPaths() {
+    let content = []
+
+    // Resolve globs from the content config
+    // TODO: When we make the postcss plugin async-capable this can become async
+    let files = fastGlob.sync(this.contentPatterns.all)
+
+    for (let file of files) {
+      content.push({
+        content: fs.readFileSync(path.resolve(file), 'utf8'),
+        extension: path.extname(file).slice(1),
+      })
+    }
+
+    // Resolve raw content in the tailwind config
+    let rawContent = this.config.content.files.filter((file) => {
+      return file !== null && typeof file === 'object'
+    })
+
+    for (let { raw: content, extension = 'html' } of rawContent) {
+      content.push({ content, extension })
+    }
+
+    return content
+  },
+
+  getContext({ createContext, cliConfigPath, root, result }) {
+    if (this.context) {
+      this.context.changedContent = this.changedContent.splice(0)
+
+      return this.context
+    }
+
+    env.DEBUG && console.time('Searching for config')
+    let configPath = findAtConfigPath(root, result) ?? cliConfigPath
+    env.DEBUG && console.timeEnd('Searching for config')
+
+    env.DEBUG && console.time('Loading config')
+    let config = this.loadConfig(configPath)
+    env.DEBUG && console.timeEnd('Loading config')
+
+    env.DEBUG && console.time('Creating context')
+    this.context = createContext(config, [])
+    Object.assign(this.context, {
+      userConfigPath: configPath,
+    })
+    env.DEBUG && console.timeEnd('Creating context')
+
+    env.DEBUG && console.time('Resolving content paths')
+    this.refreshContentPaths()
+    env.DEBUG && console.timeEnd('Resolving content paths')
+
+    if (this.watcher) {
+      env.DEBUG && console.time('Watch new files')
+      this.watcher.refreshWatchedFiles()
+      env.DEBUG && console.timeEnd('Watch new files')
+    }
+
+    env.DEBUG && console.time('Reading content files')
+    for (let file of this.readContentPaths()) {
+      this.context.changedContent.push(file)
+    }
+    env.DEBUG && console.timeEnd('Reading content files')
+
+    return this.context
+  },
+}
+
+export async function createProcessor(args, cliConfigPath) {
+  let postcss = loadPostcss()
+
+  let input = args['--input']
+  let output = args['--output']
+  let includePostCss = args['--postcss']
+  let customPostCssPath = typeof args['--postcss'] === 'string' ? args['--postcss'] : undefined
+
+  let [beforePlugins, afterPlugins, postcssOptions] = includePostCss
+    ? await loadPostCssPlugins(customPostCssPath)
+    : loadBuiltinPostcssPlugins()
+
+  let tailwindPlugin = () => {
+    return {
+      postcssPlugin: 'tailwindcss',
+      Once(root, { result }) {
+        env.DEBUG && console.time('Compiling CSS')
+        tailwind(({ createContext }) => {
+          console.error()
+          console.error('Rebuilding...')
+
+          return () => {
+            return state.getContext({ createContext, cliConfigPath, root, result })
+          }
+        })(root, result)
+        env.DEBUG && console.timeEnd('Compiling CSS')
+      },
+    }
+  }
+
+  tailwindPlugin.postcss = true
+
+  let plugins = [
+    ...beforePlugins,
+    tailwindPlugin,
+    !args['--minify'] && formatNodes,
+    ...afterPlugins,
+    !args['--no-autoprefixer'] && loadAutoprefixer(),
+    args['--minify'] && loadCssNano(),
+  ].filter(Boolean)
+
+  /** @type {import('postcss').Processor} */
+  // @ts-ignore
+  let processor = postcss(plugins)
+
+  async function readInput() {
+    // Piping in data, let's drain the stdin
+    if (input === '-') {
+      return drainStdin()
+    }
+
+    // Input file has been provided
+    if (input) {
+      return fs.promises.readFile(path.resolve(input), 'utf8')
+    }
+
+    // No input file provided, fallback to default atrules
+    return '@tailwind base; @tailwind components; @tailwind utilities'
+  }
+
+  async function build() {
+    let start = process.hrtime.bigint()
+
+    return readInput()
+      .then((css) => processor.process(css, { ...postcssOptions, from: input, to: output }))
+      .then((result) => {
+        if (!output) {
+          process.stdout.write(result.css)
+          return
+        }
+
+        return Promise.all([
+          outputFile(output, result.css),
+          result.map && outputFile(output + '.map', result.map.toString()),
+        ])
+      })
+      .then(() => {
+        let end = process.hrtime.bigint()
+        console.error()
+        console.error('Done in', (end - start) / BigInt(1e6) + 'ms.')
+      })
+  }
+
+  /**
+   * @param {{file: string, content(): Promise<string>, extension: string}[]} changes
+   */
+  async function parseChanges(changes) {
+    return Promise.all(
+      changes.map(async (change) => ({
+        content: await change.content(),
+        extension: change.extension,
+      }))
+    )
+  }
+
+  if (input !== undefined && input !== '-') {
+    state.contextDependencies.add(path.resolve(input))
+  }
+
+  return {
+    build,
+    watch: async () => {
+      state.watcher = createWatcher(args, {
+        state,
+
+        /**
+         * @param {{file: string, content(): Promise<string>, extension: string}[]} changes
+         */
+        async rebuild(changes) {
+          let needsNewContext = changes.some((change) => {
+            return (
+              state.configDependencies.has(change.file) ||
+              state.contextDependencies.has(change.file)
+            )
+          })
+
+          if (needsNewContext) {
+            state.context = null
+          } else {
+            for (let change of await parseChanges(changes)) {
+              state.changedContent.push(change)
+            }
+          }
+
+          return build()
+        },
+      })
+
+      await build()
+    },
+  }
+}
diff --git a/src/cli/build/utils.js b/src/cli/build/utils.js
new file mode 100644
--- /dev/null
+++ b/src/cli/build/utils.js
@@ -0,0 +1,76 @@
+// @ts-check
+
+import fs from 'fs'
+import path from 'path'
+
+export function indentRecursive(node, indent = 0) {
+  node.each &&
+    node.each((child, i) => {
+      if (!child.raws.before || !child.raws.before.trim() || child.raws.before.includes('\n')) {
+        child.raws.before = `\n${node.type !== 'rule' && i > 0 ? '\n' : ''}${'  '.repeat(indent)}`
+      }
+      child.raws.after = `\n${'  '.repeat(indent)}`
+      indentRecursive(child, indent + 1)
+    })
+}
+
+export function formatNodes(root) {
+  indentRecursive(root)
+  if (root.first) {
+    root.first.raws.before = ''
+  }
+}
+
+/**
+ * When rapidly saving files atomically a couple of situations can happen:
+ * - The file is missing since the external program has deleted it by the time we've gotten around to reading it from the earlier save.
+ * - The file is being written to by the external program by the time we're going to read it and is thus treated as busy because a lock is held.
+ *
+ * To work around this we retry reading the file a handful of times with a delay between each attempt
+ *
+ * @param {string} path
+ * @param {number} tries
+ * @returns {Promise<string | undefined>}
+ * @throws {Error} If the file is still missing or busy after the specified number of tries
+ */
+export async function readFileWithRetries(path, tries = 5) {
+  for (let n = 0; n <= tries; n++) {
+    try {
+      return await fs.promises.readFile(path, 'utf8')
+    } catch (err) {
+      if (n !== tries) {
+        if (err.code === 'ENOENT' || err.code === 'EBUSY') {
+          await new Promise((resolve) => setTimeout(resolve, 10))
+
+          continue
+        }
+      }
+
+      throw err
+    }
+  }
+}
+
+export function drainStdin() {
+  return new Promise((resolve, reject) => {
+    let result = ''
+    process.stdin.on('data', (chunk) => {
+      result += chunk
+    })
+    process.stdin.on('end', () => resolve(result))
+    process.stdin.on('error', (err) => reject(err))
+  })
+}
+
+export async function outputFile(file, newContents) {
+  try {
+    let currentContents = await fs.promises.readFile(file, 'utf8')
+    if (currentContents === newContents) {
+      return // Skip writing the file
+    }
+  } catch {}
+
+  // Write the file
+  await fs.promises.mkdir(path.dirname(file), { recursive: true })
+  await fs.promises.writeFile(file, newContents, 'utf8')
+}
diff --git a/src/cli/build/watching.js b/src/cli/build/watching.js
new file mode 100644
--- /dev/null
+++ b/src/cli/build/watching.js
@@ -0,0 +1,134 @@
+// @ts-check
+
+import chokidar from 'chokidar'
+import fs from 'fs'
+import micromatch from 'micromatch'
+import normalizePath from 'normalize-path'
+import path from 'path'
+
+import { readFileWithRetries } from './utils.js'
+
+/**
+ *
+ * @param {*} args
+ * @param {{ state, rebuild(changedFiles: any[]): Promise<any> }} param1
+ * @returns {{
+ *   fswatcher: import('chokidar').FSWatcher,
+ *   refreshWatchedFiles(): void,
+ * }}
+ */
+export function createWatcher(args, { state, rebuild }) {
+  let shouldPoll = args['--poll']
+  let shouldCoalesceWriteEvents = shouldPoll || process.platform === 'win32'
+
+  // Polling interval in milliseconds
+  // Used only when polling or coalescing add/change events on Windows
+  let pollInterval = 10
+
+  let watcher = chokidar.watch([], {
+    // Force checking for atomic writes in all situations
+    // This causes chokidar to wait up to 100ms for a file to re-added after it's been unlinked
+    // This only works when watching directories though
+    atomic: true,
+
+    usePolling: shouldPoll,
+    interval: shouldPoll ? pollInterval : undefined,
+    ignoreInitial: true,
+    awaitWriteFinish: shouldCoalesceWriteEvents
+      ? {
+          stabilityThreshold: 50,
+          pollInterval: pollInterval,
+        }
+      : false,
+  })
+
+  let chain = Promise.resolve()
+  let pendingRebuilds = new Set()
+  let changedContent = []
+
+  /**
+   *
+   * @param {*} file
+   * @param {(() => Promise<string>) | null} content
+   */
+  function recordChangedFile(file, content = null) {
+    file = path.resolve(file)
+
+    content = content ?? (async () => await fs.promises.readFile(file, 'utf8'))
+
+    changedContent.push({
+      file,
+      content,
+      extension: path.extname(file).slice(1),
+    })
+
+    chain = chain.then(() => rebuild(changedContent))
+
+    return chain
+  }
+
+  watcher.on('change', (file) => recordChangedFile(file))
+  watcher.on('add', (file) => recordChangedFile(file))
+
+  // Restore watching any files that are "removed"
+  // This can happen when a file is pseudo-atomically replaced (a copy is created, overwritten, the old one is unlinked, and the new one is renamed)
+  // TODO: An an optimization we should allow removal when the config changes
+  watcher.on('unlink', (file) => {
+    file = normalizePath(file)
+
+    // Only re-add the file if it's not covered by a dynamic pattern
+    if (!micromatch.some([file], state.contentPatterns.dynamic)) {
+      watcher.add(file)
+    }
+  })
+
+  // Some applications such as Visual Studio (but not VS Code)
+  // will only fire a rename event for atomic writes and not a change event
+  // This is very likely a chokidar bug but it's one we need to work around
+  // We treat this as a change event and rebuild the CSS
+  watcher.on('raw', (evt, filePath, meta) => {
+    if (evt !== 'rename') {
+      return
+    }
+
+    let watchedPath = meta.watchedPath
+
+    // Watched path might be the file itself
+    // Or the directory it is in
+    filePath = watchedPath.endsWith(filePath) ? watchedPath : path.join(watchedPath, filePath)
+
+    // Skip this event since the files it is for does not match any of the registered content globs
+    if (!micromatch.some([filePath], state.contentPatterns.all)) {
+      return
+    }
+
+    // Skip since we've already queued a rebuild for this file that hasn't happened yet
+    if (pendingRebuilds.has(filePath)) {
+      return
+    }
+
+    pendingRebuilds.add(filePath)
+
+    chain = chain.then(async () => {
+      let content
+
+      try {
+        content = await readFileWithRetries(path.resolve(filePath))
+      } finally {
+        pendingRebuilds.delete(filePath)
+      }
+
+      return recordChangedFile(filePath, () => content)
+    })
+  })
+
+  return {
+    fswatcher: watcher,
+
+    refreshWatchedFiles() {
+      watcher.add(Array.from(state.contextDependencies))
+      watcher.add(Array.from(state.configDependencies))
+      watcher.add(state.contentPatterns.all)
+    },
+  }
+}
diff --git a/src/cli/help/index.js b/src/cli/help/index.js
new file mode 100644
--- /dev/null
+++ b/src/cli/help/index.js
@@ -0,0 +1,70 @@
+// @ts-check
+import packageJson from '../../../package.json'
+
+export function help({ message, usage, commands, options }) {
+  let indent = 2
+
+  // Render header
+  console.log()
+  console.log(`${packageJson.name} v${packageJson.version}`)
+
+  // Render message
+  if (message) {
+    console.log()
+    for (let msg of message.split('\n')) {
+      console.log(msg)
+    }
+  }
+
+  // Render usage
+  if (usage && usage.length > 0) {
+    console.log()
+    console.log('Usage:')
+    for (let example of usage) {
+      console.log(' '.repeat(indent), example)
+    }
+  }
+
+  // Render commands
+  if (commands && commands.length > 0) {
+    console.log()
+    console.log('Commands:')
+    for (let command of commands) {
+      console.log(' '.repeat(indent), command)
+    }
+  }
+
+  // Render options
+  if (options) {
+    let groupedOptions = {}
+    for (let [key, value] of Object.entries(options)) {
+      if (typeof value === 'object') {
+        groupedOptions[key] = { ...value, flags: [key] }
+      } else {
+        groupedOptions[value].flags.push(key)
+      }
+    }
+
+    console.log()
+    console.log('Options:')
+    for (let { flags, description, deprecated } of Object.values(groupedOptions)) {
+      if (deprecated) continue
+
+      if (flags.length === 1) {
+        console.log(
+          ' '.repeat(indent + 4 /* 4 = "-i, ".length */),
+          flags.slice().reverse().join(', ').padEnd(20, ' '),
+          description
+        )
+      } else {
+        console.log(
+          ' '.repeat(indent),
+          flags.slice().reverse().join(', ').padEnd(24, ' '),
+          description
+        )
+      }
+    }
+  }
+
+  console.log()
+}
diff --git a/src/cli/index.js b/src/cli/index.js
new file mode 100644
--- /dev/null
+++ b/src/cli/index.js
@@ -0,0 +1,3 @@
+export * from './build'
+export * from './config'
+export * from './content'
diff --git a/src/cli/init/index.js b/src/cli/init/index.js
new file mode 100644
--- /dev/null
+++ b/src/cli/init/index.js
@@ -0,0 +1,50 @@
+// @ts-check
+
+import fs from 'fs'
+import path from 'path'
+
+export function init(args, configs) {
+  let messages = []
+
+  let tailwindConfigLocation = path.resolve(args['_'][1] ?? `./${configs.tailwind}`)
+  if (fs.existsSync(tailwindConfigLocation)) {
+    messages.push(`${path.basename(tailwindConfigLocation)} already exists.`)
+  } else {
+    let stubFile = fs.readFileSync(
+      args['--full']
+        ? path.resolve(__dirname, '../../../stubs/defaultConfig.stub.js')
+        : path.resolve(__dirname, '../../../stubs/simpleConfig.stub.js'),
+      'utf8'
+    )
+
+    // Change colors import
+    stubFile = stubFile.replace('../colors', 'tailwindcss/colors')
+
+    fs.writeFileSync(tailwindConfigLocation, stubFile, 'utf8')
+
+    messages.push(`Created Tailwind CSS config file: ${path.basename(tailwindConfigLocation)}`)
+  }
+
+  if (args['--postcss']) {
+    let postcssConfigLocation = path.resolve(`./${configs.postcss}`)
+    if (fs.existsSync(postcssConfigLocation)) {
+      messages.push(`${path.basename(postcssConfigLocation)} already exists.`)
+    } else {
+      let stubFile = fs.readFileSync(
+        path.resolve(__dirname, '../../../stubs/defaultPostCssConfig.stub.js'),
+        'utf8'
+      )
+
+      fs.writeFileSync(postcssConfigLocation, stubFile, 'utf8')
+
+      messages.push(`Created PostCSS config file: ${path.basename(postcssConfigLocation)}`)
+    }
+  }
+
+  if (messages.length > 0) {
+    console.log()
+    for (let message of messages) {
+      console.log(message)
+    }
+  }
+}
diff --git a/src/cli/shared.js b/src/cli/shared.js
new file mode 100644
--- /dev/null
+++ b/src/cli/shared.js
@@ -0,0 +1,5 @@
+// @ts-check
+
+export const env = {
+  DEBUG: process.env.DEBUG !== undefined && process.env.DEBUG !== '0',
+}
diff --git a/src/index.js b/src/index.js
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,7 @@
 import setupTrackingContext from './lib/setupTrackingContext'
 import processTailwindFeatures from './processTailwindFeatures'
 import { env } from './lib/sharedState'
+import { findAtConfigPath } from './lib/findAtConfigPath'
 
 module.exports = function tailwindcss(configOrPath) {
   return {
@@ -13,6 +14,10 @@ module.exports = function tailwindcss(configOrPath) {
           return root
         },
       function (root, result) {
+        // Use the path for the `@config` directive if it exists, otherwise use the
+        // path for the file being processed
+        configOrPath = findAtConfigPath(root, result) ?? configOrPath
+
         let context = setupTrackingContext(configOrPath)
 
         if (root.type === 'document') {
diff --git a/src/lib/findAtConfigPath.js b/src/lib/findAtConfigPath.js
new file mode 100644
--- /dev/null
+++ b/src/lib/findAtConfigPath.js
@@ -0,0 +1,50 @@
+import fs from 'fs'
+import path from 'path'
+
+/**
+ * Find the @config at-rule in the given CSS AST and return the relative path to the config file
+ *
+ * @param {import('postcss').Root} root
+ * @param {import('postcss').Result} result
+ */
+export function findAtConfigPath(root, result) {
+  let configPath = null
+  let relativeTo = null
+
+  root.walkAtRules('config', (rule) => {
+    relativeTo = rule.source?.input.file ?? result.opts.from ?? null
+
+    if (relativeTo === null) {
+      throw rule.error(
+        'The `@config` at-rule cannot be used without a `from` option being set on the PostCSS config.'
+      )
+    }
+
+    if (configPath) {
+      throw rule.error('Only `@config` at-rule is allowed per file.')
+    }
+
+    let matches = rule.params.match(/(['"])(.*?)\1/)
+    if (!matches) {
+      throw rule.error(
+        'The `@config` at-rule must be followed by a string containing the path to the config file.'
+      )
+    }
+
+    let inputPath = matches[2]
+    if (path.isAbsolute(inputPath)) {
+      throw rule.error('The `@config` at-rule cannot be used with an absolute path.')
+    }
+
+    configPath = path.resolve(path.dirname(relativeTo), inputPath)
+    if (!fs.existsSync(configPath)) {
+      throw rule.error(
+        `The config file at "${inputPath}" does not exist. Make sure the path is correct and the file exists.`
+      )
+    }
+
+    rule.remove()
+  })
+
+  return configPath ? configPath : null
+}

diff --git a/integrations/tailwindcss-cli/tests/integration.test.js b/integrations/tailwindcss-cli/tests/integration.test.js
--- a/integrations/tailwindcss-cli/tests/integration.test.js
+++ b/integrations/tailwindcss-cli/tests/integration.test.js
@@ -1,3 +1,4 @@
+let fs = require('fs')
 let $ = require('../../execute')
 let { css, html, javascript } = require('../../syntax')
 
@@ -88,6 +89,108 @@ describe('static build', () => {
       `
     )
   })
+
+  it('can read from a config file from an @config directive', async () => {
+    await writeInputFile('index.html', html`<div class="bg-yellow"></div>`)
+    await writeInputFile(
+      'index.css',
+      css`
+        @config "./tailwind.config.js";
+        @tailwind base;
+        @tailwind components;
+        @tailwind utilities;
+      `
+    )
+    await writeInputFile(
+      'tailwind.config.js',
+      javascript`
+        module.exports = {
+          content: {
+            relative: true,
+            files: ['./index.html'],
+          },
+          theme: {
+            extend: {
+              colors: {
+                yellow: '#ff0',
+              }
+            },
+          },
+          corePlugins: {
+            preflight: false,
+          },
+        }
+      `
+    )
+
+    await $('node ../../lib/cli.js -i ./src/index.css -o ./dist/main.css', {
+      env: { NODE_ENV: 'production' },
+    })
+
+    expect(await readOutputFile('main.css')).toIncludeCss(
+      css`
+        .bg-yellow {
+          --tw-bg-opacity: 1;
+          background-color: rgb(255 255 0 / var(--tw-bg-opacity));
+        }
+      `
+    )
+  })
+
+  it('can read from a config file from an @config directive inside an @import from postcss-import', async () => {
+    await fs.promises.mkdir('./src/config', { recursive: true })
+
+    await writeInputFile('index.html', html`<div class="bg-yellow"></div>`)
+    await writeInputFile(
+      'config/myconfig.css',
+      css`
+        @config "../tailwind.config.js";
+      `
+    )
+    await writeInputFile(
+      'index.css',
+      css`
+        @import './config/myconfig';
+        @import 'tailwindcss/base';
+        @import 'tailwindcss/components';
+        @import 'tailwindcss/utilities';
+      `
+    )
+    await writeInputFile(
+      'tailwind.config.js',
+      javascript`
+        module.exports = {
+          content: {
+            relative: true,
+            files: ['./index.html'],
+          },
+          theme: {
+            extend: {
+              colors: {
+                yellow: '#ff0',
+              }
+            },
+          },
+          corePlugins: {
+            preflight: false,
+          },
+        }
+      `
+    )
+
+    await $('node ../../lib/cli.js -i ./src/index.css -o ./dist/main.css', {
+      env: { NODE_ENV: 'production' },
+    })
+
+    expect(await readOutputFile('main.css')).toIncludeCss(
+      css`
+        .bg-yellow {
+          --tw-bg-opacity: 1;
+          background-color: rgb(255 255 0 / var(--tw-bg-opacity));
+        }
+      `
+    )
+  })
 })
 
 describe('watcher', () => {
@@ -381,4 +484,125 @@ describe('watcher', () => {
 
     return runningProcess.stop()
   })
+
+  test('listens for changes to the @config directive', async () => {
+    await writeInputFile('index.html', html`<div class="bg-yellow"></div>`)
+    await writeInputFile(
+      'index.css',
+      css`
+        @config "./tailwind.config.js";
+        @tailwind base;
+        @tailwind components;
+        @tailwind utilities;
+      `
+    )
+    await writeInputFile(
+      'tailwind.config.js',
+      javascript`
+        module.exports = {
+          content: {
+            relative: true,
+            files: ['./index.html'],
+          },
+          theme: {
+            extend: {
+              colors: {
+                yellow: '#ff0',
+              }
+            },
+          },
+          corePlugins: {
+            preflight: false,
+          },
+        }
+      `
+    )
+    await writeInputFile(
+      'tailwind.2.config.js',
+      javascript`
+        module.exports = {
+          content: {
+            relative: true,
+            files: ['./index.html'],
+          },
+          theme: {
+            extend: {
+              colors: {
+                yellow: '#ff7',
+              }
+            },
+          },
+          corePlugins: {
+            preflight: false,
+          },
+        }
+      `
+    )
+
+    let runningProcess = $('node ../../lib/cli.js -i ./src/index.css -o ./dist/main.css -w')
+    await runningProcess.onStderr(ready)
+
+    expect(await readOutputFile('main.css')).toIncludeCss(
+      css`
+        .bg-yellow {
+          --tw-bg-opacity: 1;
+          background-color: rgb(255 255 0 / var(--tw-bg-opacity));
+        }
+      `
+    )
+
+    await writeInputFile(
+      'index.css',
+      css`
+        @config "./tailwind.2.config.js";
+        @tailwind base;
+        @tailwind components;
+        @tailwind utilities;
+      `
+    )
+    await runningProcess.onStderr(ready)
+
+    expect(await readOutputFile('main.css')).toIncludeCss(
+      css`
+        .bg-yellow {
+          --tw-bg-opacity: 1;
+          background-color: rgb(255 255 119 / var(--tw-bg-opacity));
+        }
+      `
+    )
+
+    await writeInputFile(
+      'tailwind.2.config.js',
+      javascript`
+        module.exports = {
+          content: {
+            relative: true,
+            files: ['./index.html'],
+          },
+          theme: {
+            extend: {
+              colors: {
+                yellow: '#fff',
+              }
+            },
+          },
+          corePlugins: {
+            preflight: false,
+          },
+        }
+      `
+    )
+    await runningProcess.onStderr(ready)
+
+    expect(await readOutputFile('main.css')).toIncludeCss(
+      css`
+        .bg-yellow {
+          --tw-bg-opacity: 1;
+          background-color: rgb(255 255 255 / var(--tw-bg-opacity));
+        }
+      `
+    )
+
+    return runningProcess.stop()
+  })
 })

Resolve config file relative to the file being transformed instead of cwd
**What version of Tailwind CSS are you using?**

v3.0.2

**What build tool (or framework if it abstracts the build tool) are you using?**

Parcel 2 👋 

**What version of Node.js are you using?**

16

**What operating system are you using?**

macOS

**Describe your issue**

A "standard" PostCSS config like

```js
const path = require("path");

module.exports = {
  plugins: [
      require('tailwindcss')
  ]
}
```

causes Tailwind to search for the config file relative to the current working directory because of this

https://github.com/tailwindlabs/tailwindcss/blob/a7263a8f6faf989cb98553491d5c456d0b86de9b/src/util/resolveConfigPath.js#L46-L48

I think it should instead be relative to the file that's being processed. This way you could also have a single project with multiple config files for different parts.

This might have performance downsides if you currently search just once for a config.
