The Standup Doctor
The cure for all your standup woes.
An automated, customizable way to keep track of daily standups in markdown files.
You can view the documentation here.
About
I’ve now been at multiple companies where we post our daily standups in a chat client, such as Slack, Mattermost, or Riot. Typing out my standup every day became tedious, as I’d have to look up what I did the day before, copy and paste yesterday’s work into a new entry, and add today’s tasks. This gem automates most of this process, along with providing means of opening the file in your editor, finding and displaying entries from the command line, and posting today’s standup directly to a chat client.
In a nutshell, calling standup from the command line will open a standup file for the current month in your preferred editor. If an entry for today is already present, no text will be generated. If an entry for today doesn’t exist, one will be generated with your preferred values. When generating, if a previous entry exists, it will be added to today’s entry as your previous day’s work. See example. There’s also a very robust API if you’d like to use this in your own code somehow.
Installation
Requires Ruby 3.2 or newer.
If you don’t have the permissions to install system-wide gems, you’re probably also running an older version of ruby. I recommend installing rbenv, and then installing an up-to-date version of ruby.
Via RubyGems
Just install the gem!
gem install standup_md
To include in your project, add the following to your Gemfile.
gem "standup_md"
Manual Installation
From your terminal, clone the repository where you want it, and use rake to install the gem.
git clone https://github.com/evanthegrayt/standup_md.git cd standup_md # Use rake to build and install the gem. rake install
Zsh Completion
The gem ships with a zsh completion file at completion/zsh/_standup. RubyGems doesn’t automatically add gem-provided completion directories to zsh’s fpath, so you’ll need to add the completion directory yourself after installation.
For setup instructions and example, run the following.
standup --zsh-completion
Usage
Command Line
For the most basic usage, simply call the executable.
standup
This opens the current month’s standup file. If an entry already exists for today, nothing is added. If no entry exists for today, the previous “Current” is placed in the “Previous” section of a new entry. The format of this file is very important; you may add new entries, but don’t change any of the headers. Doing so will cause the parser to break. If you want to customize the headers, you can do so in the configuration file.
To open a previous month’s standup file, pass the date as an argument. Both YYYY-MM and YYYY-MM-DD are accepted, and both open the file for that month.
standup 2026-06 standup 2026-06-18
CLI Examples
Adding an entry for today via editor
For example, if the standup entry from yesterday reads as follows:
# 2020-04-13 ## Previous - Did something else. ## Current - Write new feature for `standup_md` - Fix bug in `standup_md` ## Impediments - None
The following scaffolding will be added for current entry at the top of the file:
# 2020-04-14 ## Previous - Write new feature for `standup_md` - Fix bug in `standup_md` ## Current - <!-- ADD TODAY'S WORK HERE --> ## Impediments - None
Copy the entry for today to clipboard
There are also flags that will print entries to the command line. There’s a full list of features below, but as a quick example, you can copy today’s entry to your clipboard without even opening your editor.
standup -p | pbcopy
Post the entry for today to Slack
You can also post today’s entry directly to a chat client. Slack is the default adapter, so standup -P and standup -P slack are equivalent. Tokens are read from environment variables at post time, and are not stored in ~/.standuprc. You can set a default channel via the config via c.post.configure_adapter(:slack, channel: "C123456").
export STANDUP_MD_SLACK_TOKEN="xoxb-your-token" standup -P slack --post-channel C123456
The same posting path is available from Ruby:
file = StandupMD::File.find_by_date(Date.today).load entry = file.entries.find(Date.today) StandupMD::Post.post(entry, adapter: :slack, channel: "C123456")
Add entry to file without opening it
You can add an entry for today without even opening your editor. Note that, if you have multiple entries, you must separate them with a comma and no spaces.
standup --no-edit --current "Work on this thing","And another thing"
Customization and Runtime Options
You can create a file in your home directory called ~/.standuprc. Settings located in this file define your application defaults. CLI flags can override those defaults for a single invocation, but they do not persist back into StandupMD.config or ~/.standuprc. You can view my config file as an example.
You’ll notice, a lot of settings don’t have the ability to be changed at runtime when calling the executable. This is because the markdown structure is very important, and changing values that affect formatting will cause problems with the markdown parser. If you don’t want to use a default, make the change in your config file before you start editing standups.
Available Config File Options and Defaults
For command-line usage, this file needs to be named ~/.standuprc. To use in a rails project, create an initializer (config/initializers/standup_md.rb).
StandupMD.configure do |c| # Defaults for how the file is formatted. # See https://evanthegrayt.github.io/standup_md/doc/StandupMD/Config/File.html c.file.header_date_format = "%Y-%m-%d" c.file.header_depth = 1 c.file.sub_header_depth = 2 c.file.current_header = "Current" c.file.previous_header = "Previous" c.file.impediments_header = "Impediments" c.file.notes_header = "Notes" c.file.sub_header_order = %w[previous current impediments notes] c.file.directory = ::File.join(ENV["HOME"], ".cache", "standup_md") c.file.bullet_character = "-" c.file.indent_width = 2 c.file.name_format = "%Y_%m.md" c.file.create = true # Defaults for entries. # See https://evanthegrayt.github.io/standup_md/doc/StandupMD/Config/Entry.html c.entry.current = ["<!-- ADD TODAY'S WORK HERE -->"] c.entry.previous = [] c.entry.impediments = ["None"] c.entry.notes = [] # Defaults for executable runtime behavior. # See https://evanthegrayt.github.io/standup_md/doc/StandupMD/Config/Cli.html c.cli.date = Date.today c.cli.editor = "vim" # Checks $VISUAL and $EDITOR first, in that order c.cli.verbose = false c.cli.edit = true c.cli.write = true c.cli.print = false c.cli.post = false c.cli.post_adapter = nil c.cli.post_channel = nil c.cli.auto_fill_previous = true c.cli.preference_file = ::File.expand_path(::File.join(ENV["HOME"], ".standuprc")) # Defaults for posting standups to chat clients. c.post.default_adapter = :slack c.post.title = nil c.post.configure_adapter( :slack, channel: "C123456", token_env: "STANDUP_MD_SLACK_TOKEN" ) end
Any options not set in this file will retain their default values. Note that if you change name_format, and don’t use a month or year, there will only ever be one standup file. This could cause issues long-term, as the files will get large over time and possibly cause performance issues.
Executable Flags
Some defaults can be overridden for a single CLI invocation. They are as follows.
--current ARRAY List of current entry's tasks
--previous ARRAY List of previous entry's tasks
--impediments ARRAY List of impediments for current entry
--notes ARRAY List of notes for current entry
-E, --editor EDITOR Editor to use for opening standup files
-d, --directory DIRECTORY The directory where standup files are located
-w, --[no-]write Write current entry if it doesn't exist. Default is true
-a, --[no-]auto-fill-previous Auto-generate 'previous' tasks for new entries
-e, --[no-]edit Open the file in the editor. Default is true
-v, --[no-]verbose Verbose output. Default is false.
--zsh-completion Print zsh completion setup instructions
-p, --print [DATE] Print current entry.
If DATE is passed, will print entry for DATE, if it exists.
DATE must be in the same format as the entry header date.
-P, --post [PLATFORM] Post current entry to a chat client. Defaults to Slack.
If PLATFORM is passed, use that post adapter.
--post-channel CHANNEL Channel, room, or conversation to post to
Posting and Secrets
For a real-world example of how to get the Slack integration working, see the guide.
The built-in Slack adapter sends the rendered markdown entry to Slack’s chat.postMessage API. It needs a Slack token with the chat:write scope and a channel or conversation. Channel-like IDs such as C123456, G123456, and D123456 are the most reliable values to use. By default, the token is read from STANDUP_MD_SLACK_TOKEN.
The recommended pattern is to keep secret values in the environment and store only non-secret adapter defaults in ~/.standuprc:
StandupMD.configure do |c| c.post.configure_adapter(:slack, channel: "C123456") end
Most chat clients now prefer messages to come from an installed app or bot instead of a long-lived user token. That keeps permissions clearer, but it also means the visible sender might be a general name like “StandupMD” rather than the person whose update is being posted. Set c.post.title to identify the standup owner in the message title without changing the markdown files that StandupMD parses.
StandupMD.configure do |c| c.post.title = "%s - Evan Gray" end
The %s placeholder is replaced with the normal entry title, usually the entry date, so a stored # 2026-06-27 entry posts as # 2026-06-27 - Evan Gray.
To use a different token variable, set token_env.
StandupMD.configure do |c| c.post.configure_adapter( :slack, channel: "C123456", token_env: "WORK_SLACK_TOKEN" ) end
Custom Post Adapters
Adapters are registered in ~/.standuprc. They receive a StandupMD::Post::Message, which includes the rendered markdown text and the channel passed through StandupMD::Post.post or --post-channel.
class TeamsAdapter def initialize(options = {}) @options = options end def post(message) channel = message.channel || @options[:channel] token = ENV.fetch("TEAMS_TOKEN") # Send message.text to channel with token. StandupMD::Post::Result.success( adapter: message.adapter, channel: channel ) end end StandupMD.configure do |c| c.post.register_adapter(:teams, TeamsAdapter) c.post.configure_adapter(:teams, channel: "team-channel-id") end
Custom adapters can be used from either the CLI or the Ruby API:
StandupMD::Post.post(entry, adapter: :teams, channel: "team-channel-id")
For request-scoped API usage, copy the global defaults and pass the copy into the operation:
runtime = StandupMD.config.copy runtime.post.default_adapter = :teams runtime.post.configure_adapter(:teams, channel: "team-channel-id") StandupMD::Post.post(entry, config: runtime)
Using Existing Standup Files
If you already have a directory of existing standup files, you can use them, but they must be in a format that the parser can understand. The default is:
# 2020-05-01 ## Previous - task ## Current - task ## Impediments - impediment ## Notes - notes, if any are present
The order, words, date format, and header level are all customizable, but the overall format must be the same. If customization is necessary, set the defaults or pass a runtime config before reading the file, or else the parser will error.
For example, if you wanted the format to be as follows:
## 05/01/2020 ### Today * task ### Yesterday * task ### Hold-ups * impediment ### Notes * notes, if any are present
Your ~/.standuprc should contain:
StandupMD.configure do |c| c.file.header_depth = 2 c.file.sub_header_depth = 3 c.file.current_header = "Today" c.file.previous_header = "Yesterday" c.file.impediments_header = "Hold-ups" c.file.bullet_character = "*" c.file.indent_width = 2 c.file.header_date_format = "%m/%d/%Y" c.file.sub_header_order = %w[current previous impediments notes] end
API
The API is fully documented in the RDoc Documentation.
This was mainly written as a command line utility, but the API is available for use in your own projects. StandupMD.config stores application defaults. For web requests, jobs, or any other multi-call environment, copy those defaults and pass the copy into the operation you are running.
StandupMD::File handles reading and writing files on disk. The markdown parser handles markdown strings:
parser = StandupMD::Parsers::Markdown.new entries = parser.parse(File.read("2026_06.md")) markdown = parser.render(entries, start_date: entries.first.date, end_date: entries.last.date)
runtime = StandupMD.config.copy runtime.file.directory = "/tmp/request-standups" runtime.entry.current = ["Work scoped to this request"] file = StandupMD::File.find_by_date(Date.today, config: runtime.file).load entry = StandupMD::Entry.create(config: runtime.entry) file.entries << entry file.write
API Examples
Adding an entry for today
require "standup_md" StandupMD.configure do |c| c.file.current_header = "Today" end file = StandupMD::File.find_by_date(Date.today).load entry = StandupMD::Entry.create(current: ["Stuff I will do today"]) file.entries << entry file.write
The above example uses global defaults. To keep runtime choices scoped to one request, copy the defaults and pass the copy into each operation.
require "standup_md" runtime = StandupMD.config.copy runtime.file.current_header = "Today" runtime.entry.current = ["Stuff I will do today"] file = StandupMD::File.find_by_date(Date.today, config: runtime.file).load entry = StandupMD::Entry.create(config: runtime.entry) file.entries << entry file.write
Finding a past entry
require "standup_md" date = Date.new(2020, 04, 15) file = StandupMD::File.find_by_date(date).load entry = file.entries.find(date)
Vim
While there’s no official support for vim, you can add this to your vimrc file, or something like ~/.vim/plugin/standup.vim.
command! -complete=custom,<SID>StandupCompletion -nargs=? -bang Standup
\ call <SID>OpenStandupFile(<bang>0, <f-args>)
function! s:StandupCompletion(...) abort
let l:dir = get(g:, 'standup_dir', $HOME . '/.cache/standup_md') . '/'
if !isdirectory(l:dir) | return '' | endif
return join(map(glob(l:dir . '*.md', 0, 1), "fnamemodify(v:val, ':t')"), "\n")
endfunction
function! s:OpenStandupFile(split, ...)
let l:dir = get(g:, 'standup_dir', $HOME . '/.cache/standup_md') . '/'
let l:file = a:0 ? a:1 : get(g:, 'standup_file', strftime('%Y_%m.md'))
call system('standup --no-edit')
execute a:split ? 'vsplit' : 'split' l:dir . l:file
endfunction
This makes the :Standup command, which opens the standup file in a split, while :Standup! opens it in a vertical split. If a file is passed to the command, that file will be opened. There’s tab completion for this. Lastly, it allows for a few variables to be set for customization.
g:standup_dir = $HOME . '/.cache/standup_md' " the directory where your files are
g:standup_file = strftime('%Y_%m.md') " the file format to use
Support this project
I love knowing when people find my work useful. Any kind of support is very much appreciated!
-
⭐️ Like the project? Star the repository!
-
❤️ Love the project? Follow me on GitHub!
-
💸 Really love it? Consider buying me a tea!