diff --git a/bin/noblogs b/bin/noblogs
new file mode 100644
index 0000000000000000000000000000000000000000..70ecac23c23448df3ab207c36e963166fa64bbde
--- /dev/null
+++ b/bin/noblogs
@@ -0,0 +1,430 @@
+#!/suca/dai/php5 -f
+<?php
+
+include('/usr/local/lib/noblogs-cli/noblogs.php');
+
+function help() {
+?>
+Usage: noblogs <COMMAND> [<ARGS>...]
+
+Known commands:
+
+   info BLOG [...]
+      Print some basic information about one or more blogs.
+
+   connectdb BLOG
+      Connect to the MySQL instance that has the blog db.
+
+   get-option OPTION_NAME BLOG [...]
+      Print the value of an option for the specified blogs.
+
+   set-option OPTION_NAME OPTION_VALUE BLOG [...]
+      Set the value of an option for the specified blogs.
+
+   print-all-blogs
+      Print a list of all existing blog IDs.
+
+   print-local-blogs
+      Print a list of just those blog IDs that are hosted on this
+      server.
+
+   dump-shards
+      Print a JSON dictionary representing the full backend -> blogs
+      map.
+
+   check-upgrade BLOG [...]
+      Check whether the specified blogs need to be upgraded.
+
+   upgrade BLOG [...]
+      Upgrade the specified blogs. NOTE: upgrading a blog that is
+      not local is dangerous! Use the 'on-local-blogs' wrapper.
+
+   run-cron BLOG [...]
+      Run cron jobs for the specified blogs. NOTE: running cron jobs
+      for a blog that is not local is dangerous! Use the 'on-local-blogs'
+      wrapper.
+
+   fix-rewrites BLOG [...]
+      Fix broken rewrite rules for the specified blogs. NOTE: dangerous!!!!
+
+   close-comments-if-inactive BLOG [...]
+      Closes old posts for comments on non-active blogs.
+
+   remove-network-upgrade-message
+      Remove the 'network upgrade' message when all the blogs have
+      been upgraded individually.
+
+   update-friend-domains
+      Update the list of 'friend' email domains.
+
+   check-updates
+      Check for updates of core, plugins and themes.
+      Options:
+	-force	prints the known updates when not checking for the new ones
+	-mail	sends email to ai-changes@investici.org
+	-json	print JSON output
+
+   set-readonly on|off
+      Set read-only mode for the local noblogs installation. The argument
+      must be either the literal 'on' or 'off'.
+
+<?php
+  exit(1);
+}
+
+// 'info': Return information about a single blog.
+function do_info($args) {
+  foreach ($args as $arg) {
+    $blog = noblogs_get_blog($arg);
+    if (!$blog) {
+      die("Blog not found.\n");
+    }
+    $status = 'active';
+    if ($blog->deleted) {
+      $status = 'deleted';
+    } elseif ($blog->archived) {
+      $status = 'archived';
+    }
+    $dbinfo = noblogs_get_backend_for_blog($blog->blog_id);
+
+    echo "ID:          {$blog->blog_id}\n";
+    echo "Name:        {$blog->domain}\n";
+    echo "Host:        {$dbinfo['host']}\n";
+    echo "Status:      {$status}\n";
+    echo "Registered:  {$blog->registered}\n";
+    echo "Last Update: {$blog->last_updated}\n";
+    echo "\n";
+  }
+}
+
+
+// 'connectdb': Connect to the database hosting a specific blog.
+function do_connectdb($args) {
+  $blog = noblogs_get_blog($args[0]);
+  if (!$blog) {
+    die("Blog not found.\n");
+  }
+  $backend = noblogs_get_backend_for_blog($blog->blog_id);
+
+  echo "ID:  {$blog->blog_id}\n";
+  $cmd = "mysql -A -h {$backend['host']} -P {$backend['port']} -u{$backend['user']} -p{$backend['password']} {$backend['db']}";
+  echo "$cmd\n";
+  //system($cmd);
+}
+
+
+// 'get-option': Print the value of a blog option.
+function do_get_option($args) {
+  $option = $args[0];
+  if (!$option) {
+    echo "Not enough arguments\n";
+    help();
+  }
+
+  foreach (array_splice($args, 1) as $arg) {
+    $blog = noblogs_get_blog($arg);
+    if (!$blog) {
+      echo "Blog {$arg} not found.\n";
+      continue;
+    }
+
+    switch_to_blog($blog->blog_id);
+    $value = get_option($option);
+    if ($value) {
+      if (is_array($value)) {
+        // Use JSON as a string representation.
+        $value = json_encode($value);
+      }
+      echo "{$arg}: {$value}\n";
+    }
+    restore_current_blog();
+  }
+}
+
+// 'set-option': Set the value of a blog option.
+function do_set_option($args) {
+    $option = array_shift($args);
+    if (!$option) {
+        echo "Not enough arguments\n";
+        help();
+    }
+    $value = array_shift($args);
+    if ($value === null) {
+        echo "Not enough arguments\n";
+        help();
+    }
+
+
+    foreach ($args as $arg) {
+        $blog = noblogs_get_blog($arg);
+        if (!$blog) {
+            echo "Blog {$arg} not found.\n";
+            continue;
+        }
+
+        switch_to_blog($blog->blog_id);
+        update_option($option, $value);
+        $nv = get_option($option);
+        echo "{$arg}: {$nv}\n";
+        restore_current_blog();
+    }
+}
+
+
+// 'dump-shards': Print a JSON dictionary representing the full map
+// of backend -> blogs database mappings.
+function do_dump_shards($args) {
+  $backend_map = noblogs_get_backend_map();
+  echo json_encode($backend_map);
+  echo "\n";
+}
+
+
+// 'print-all-blogs': List all blog IDs.
+function do_print_all_blogs($args) {
+  $blogs = noblogs_get_blogs();
+  foreach ($blogs as $blog) {
+    echo "{$blog->blog_id}\n";
+  }
+}
+
+
+// 'print-local-blogs': List the blog IDs that are local to this machine.
+function do_print_local_blogs($args) {
+  $local_blogs = noblogs_get_local_blogs();
+  foreach ($local_blogs as $b) {
+    echo $b . "\n";
+  }
+}
+
+
+// 'remove-network-upgrade-message': Remove the annoying "network
+// upgrade necessary" banner from the dashboard.
+function do_remove_network_upgrade_message($args) {
+  global $wp_db_version;
+  update_site_option('wpmu_upgrade_site', $wp_db_version);
+}
+
+
+// 'check-upgrade': Check if a blog needs to be upgraded.
+function do_check_upgrade($args) {
+  global $wp_db_version;
+  foreach ($args as $arg) {
+    $blog = noblogs_get_blog($arg);
+    if (!$blog) {
+      echo "Blog {$arg} not found.\n";
+      continue;
+    }
+    switch_to_blog($blog->blog_id);
+    $db_version = get_option('db_version');
+    if ($db_version != $wp_db_version) {
+      echo "{$arg}: UPGRADE\n";
+    } else {
+      echo "{$arg}: ok\n";
+    }
+    restore_current_blog();
+  }
+}
+
+
+// 'upgrade': Upgrade a blog.
+function do_upgrade($args) {
+  include(NOBLOGS_ROOT . "/wp-admin/includes/upgrade.php");
+
+  foreach ($args as $arg) {
+    $blog = noblogs_get_blog($arg);
+    if (!$blog) {
+      echo "Blog {$arg} not found.\n";
+      continue;
+    }
+    switch_to_blog($blog->blog_id);
+    wp_upgrade();
+    echo "{$arg}: ok\n";
+    restore_current_blog();
+  }
+}
+
+
+// 'run-cron': Run cron jobs.
+function do_run_cron($args) {
+  foreach ($args as $arg) {
+    $blog = noblogs_get_blog($arg);
+    if (!$blog) {
+      echo "Blog {$arg} not found.\n";
+      continue;
+    }
+    switch_to_blog($blog->blog_id);
+    noblogs_run_cron_for_current_blog();
+    echo "{$arg}: ok\n";
+    restore_current_blog();
+  }
+}
+
+
+// 'fix-rewrites': Fix rewrite rules
+function do_fix_rewrites($args) {
+    global $wp_rewrite;
+    foreach ($args as $arg) {
+        $blog = noblogs_get_blog($arg);
+        if (!$blog) {
+           echo "Blog {$arg} not found.\n";
+           continue;
+        }
+        switch_to_blog($blog->blog_id);
+        $wp_rewrite->init();
+        create_initial_taxonomies();
+        $wp_rewrite->flush_rules();
+        echo "{$arg}: ok\n";
+        restore_current_blog();
+    }
+}
+
+function do_update_friend_domains($args) {
+  $domains = noblogs_list_friend_domains();
+  update_site_option('limited_email_domains', $domains);
+  echo "Update done.\n";
+}
+
+function do_check_spam($args) {
+  global $wpdb;
+  $spamcount = 0;
+  foreach ($args as $arg) {
+    $blog = noblogs_get_blog($arg);
+    if (!$blog) {
+      echo "Blog {$arg} not found.\n";
+      continue;
+    }
+    $spam = $wpdb->get_var("SELECT count(*) FROM wp_".$blog->blog_id ."_comments where comment_approved = 'spam';");
+    $spamcount+=$spam;
+    printf("%s - %d : %d\n", $blog->domain, $blog->blog_id, $spam);
+  }
+  printf("Found %d spam comments\n", $spamcount);
+}
+
+function do_nuke_spam($args) {
+  global $wpdb;
+  foreach ($args as $arg) {
+    $blog = noblogs_get_blog($arg);
+    if (!$blog) {
+      echo "Blog {$arg} not found.\n";
+      continue;
+    }
+    $spam = $wpdb->get_var("DELETE FROM wp_".$blog->blog_id ."_comments where comment_approved = 'spam' and comment_date < '".date('Y-m-d',time() - (86400 * 60))."';");
+    printf("%s - %d : %d\n", $blog->domain, $blog->blog_id, $spam);
+  }
+}
+
+function do_close_comments_if_inactive($args) {
+    global $wpdb;
+    foreach ($args as $arg) {
+        $blog = noblogs_get_blog($arg);
+        if (!$blog) {
+            echo "Blog {$arg} not found.\n";
+            continue;
+        }
+        switch_to_blog($blog->blog_id);
+        if (get_option('close_comments_for_old_posts') == '1') {
+            echo "Blog {$blog->domain} already closed to comments, skipping.\n";
+            continue;
+        } else if (noblogs_is_stale()) {
+            echo "Closing comments on blog {$blog->domain}.\n";
+            update_option('close_comments_for_old_posts', '1');
+            update_option('close_comments_days_old', '90');
+        } else {
+            echo "Leaving comments opened on blog {$blog->domain}.\n";
+        }
+    }
+}
+
+function check_updates_parse_flags($args) {
+  $flags = array();
+  foreach (array('json', 'force', 'mail') as $f){
+    $flags[$f] = in_array('-' . $f, $args);
+  }
+  return $flags;
+}
+
+// 'check-updates': check for core, plugins and theme updates
+function do_check_updates($args) {
+  $alertmail = "root@localhost";
+  $flags = check_updates_parse_flags($args);
+  global $wp_version;
+
+  $updates = array();
+  $updates['core'] = array();
+  $updates['plugins'] = array();
+  $updates['themes'] = array();
+
+  $txt = '';
+
+  $check = $flags['force'];
+  $check = wp_version_check() || $check;
+  $check = wp_update_plugins() || $check;
+  $check = wp_update_themes() || $check;
+  if (!$check) {
+    $flags['json'] || print "Check is too soon, exiting\n" ;
+    exit(1);
+  }
+
+  foreach(get_site_transient('update_core')->updates as $update) {
+    if($update->current != $wp_version) {
+      $txt .= sprintf("Core version: %s\n\n", $update->current);
+      $updates['core'][] = array('version' => $update->current,
+				 'url' => $update->package);
+    }
+  }
+
+  foreach(get_site_transient('update_plugins')->response as $update) {
+    $txt .= sprintf("Plugin: %s\nVersion: %s\nURL: %s\n\n",
+                           $update->slug, $update->new_version, $update->package);
+    $updates['plugins'][] = array('name' => $update->slug,
+                                  'version' => $update->new_version,
+                                  'url' => $update->package);
+  }
+
+  foreach(get_site_transient('update_themes')->response as $name => $update) {
+    $txt .= sprintf("Theme: %s\nVersion: %s\nURL: %s\n\n",
+                           $name, $update['new_version'], $update['package']);
+    $updates['themes'][] = array('name' => $name,
+                                 'version' => $update['new_version'],
+                                 'url' => $update['package']);
+  }
+
+  if ($flags['mail']){
+    wp_mail($alertmail, "[Noblogs-Alert] Updates are available", $txt);
+  }
+
+  if ($flags['json']){
+    print json_encode($updates);
+  } else {
+    print $txt;
+  }
+
+}
+
+// 'set-readonly': toggle readonly mode by modifying .htaccess
+function do_set_readonly($args) {
+  $htaccess = NOBLOGS_ROOT . '/.htaccess';
+  if ($args[0] == "on") {
+    comment_with_markers($htaccess, 'readonly', false);
+  } elseif ($args[0] == "off") {
+    comment_with_markers($htaccess, 'readonly', true);
+  } else {
+    print "Argument must be either 'on' or 'off'.";
+  }
+}
+
+
+// Command-line parsing.
+$cmd = $argv[1];
+if (!$cmd) {
+  help();
+}
+
+$cmd_func = "do_" . str_replace('-', '_', $cmd);
+if (!function_exists($cmd_func)) {
+  echo "Unknown command '".$cmd."'\n\n";
+  help();
+}
+call_user_func($cmd_func, array_slice($argv, 2));
+
diff --git a/lib/editfiles.php b/lib/editfiles.php
new file mode 100644
index 0000000000000000000000000000000000000000..fbe831c71728fa60123babbed9b7702b1958eedb
--- /dev/null
+++ b/lib/editfiles.php
@@ -0,0 +1,52 @@
+<?php
+
+/* Comments/uncomments a section of a file, placed between BEGIN and
+   END markers. */
+function comment_with_markers($filename, $marker, $do_comment) {
+  if (!file_exists($filename)) {
+    return false;
+  }
+  $markerdata = explode("\n", implode('', file($filename)));
+  if (!$f = @fopen($filename, 'w')) {
+    return false;
+  }
+  $foundit = false;
+
+  $begin_marker = '# BEGIN ' . $marker;
+  $end_marker = '# END ' . $marker;
+
+  if ($markerdata) {
+    $state = false;
+    foreach ($markerdata as $n => $markerline) {
+      if (strpos($markerline, $end_marker) !== false) {
+        $state = false;
+      }
+
+      if ($state) {
+        if ($do_comment) {
+          if (substr($markerline, 0, 1) != '#') {
+            $markerline = '#' . $markerline;
+          }
+        } else {
+          if (substr($markerline, 0, 1) == '#') {
+            $markerline = substr($markerline, 1);
+          }
+        }
+      }
+
+      if ($n + 1 < count($markerdata)) {
+        fwrite($f, "{$markerline}\n");
+      } else {
+        fwrite($f, "{$markerline}");
+      }
+
+      if (strpos($markerline, $begin_marker) !== false) {
+        $state = true;
+        $foundit = true;
+      }
+    }
+  }
+
+  fclose($f);
+  return $foundit;
+}
diff --git a/lib/noblogs.php.in b/lib/noblogs.php.in
index deb2a465d6f75d493e9bb12436401d1094058a2a..7bf44204cedf59f53c0cbfe16411b3c19901e07a 100644
--- a/lib/noblogs.php.in
+++ b/lib/noblogs.php.in
@@ -9,6 +9,7 @@ define('AI_CRON_SCRIPT', true);
 require_once(dirname(__FILE__) . '/blogs.php');
 require_once(dirname(__FILE__) . '/cron.php');
 require_once(dirname(__FILE__) . '/friends.php');
+require_once(dirname(__FILE__) . '/editfiles.php');
 
 // Load the Wordpress api.
 define('WP_CACHE',false);