Publish MaintenanceShell v0.4.0: Rewrite

* Fix various security issues
  - Use in_array on the directory scan instead of
    sanitising $script and doing file_exists.
  - Catch the output of the maintenance script with AJAX
    and output it as a text node instead of echo'ing it raw
    unescaped between <pre> and </pre>, which can cause html
    escape issues with the output of the maintenance script.

* Phase out hacks in main php file, in favour of using regular
  MediaWiki interfaces for:
  - i18n messages
  - user tokens
  - user permissions

* Use FormSpecialPage to automatically take care of:
  - Visual layout and creation of HTML <form> output
  - User token handling
  - Input validation
  - User block status, rights and permissions checking
  - Simple <select> drop down menu instead of <table>
    of <a> links.

* Use ResourceLoader for delivery of javascript/css

* Implement basic parser for the cli arguments instead of
  the manual regexing, also adding unit tests for many
  edge cases that the old regex didn't support.

  To run:
  $ cd mw/core/tests/phpunit;
  $ php phpunit.php ../../extensions/MaintenanceShell/;

* Config changes:
  - Removed obsolete $wgMaintenanceShellLang
  - Renamed $maintenance_path to $wgMaintenanceShellPath

* Cleaned up directory structure and file naming to
  latest MediaWiki extension recommendations and coding
  style conventions.
diff --git a/MaintenanceShell.alias.php b/MaintenanceShell.alias.php
old mode 100755
new mode 100644
diff --git a/MaintenanceShell.hooks.php b/MaintenanceShell.hooks.php
new file mode 100644
index 0000000..67085e7
--- /dev/null
+++ b/MaintenanceShell.hooks.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * Hooks for MaintenanceShell extension
+ *
+ * @file
+ * @ingroup Extensions
+ */
+
+class MaintenanceShellHooks {
+
+	/**
+	 * Set default values.
+	 * Set from this hook so that core Setup and LocaLSettings apply first.
+	 */
+	public static function onSetup() {
+		global $wgMaintenanceShellPath, $IP;
+
+		if ( $wgMaintenanceShellPath === false ) {
+			$wgMaintenanceShellPath = $IP . '/maintenance';
+		}
+	}
+
+	public static function onUnitTestsList( Array &$files ) {
+		$files[] = __DIR__ . '/tests/MaintenanceShellArgumentsParserTest.php';
+	}
+}
diff --git a/MaintenanceShell.i18n.php b/MaintenanceShell.i18n.php
old mode 100755
new mode 100644
index 55f43a7..aaab66a
--- a/MaintenanceShell.i18n.php
+++ b/MaintenanceShell.i18n.php
@@ -4,27 +4,28 @@
 /** English
  * @author Andrew Fitzgerald
  */
-
 $messages['en'] = array(
 	'maintenanceshell'         => 'Maintenance Shell',
 	'maintenanceshell-desc'    => 'Wiki interface for maintenance scripts',
-	'maintshell-pagename'      => 'Special:MaintenanceShell',
-	'right-maintenanceshell'   => 'Access the Maintenance Shell',
-	'maintshell-installfail'   => 'MaintenanceShell did not detect the user right <b>maintenanceshell</b> assigned to any group.<br /><br /> Please see <a href="https://www.mediawiki.org/wiki/Extension:MaintenanceShell">the Extension:MaintenanceShell documentation</a> for more details.',
-	'maintshell-installfail2'  =>'<b>MaintenanceShell is configured incorrectly.</b><br />The user right <b>maintenanceshell</b> must be assigned to a user group before the extension MaintanceShell is called in <b>LocalSettings.php</b>.<br /><br />Please see the <a href="https://www.mediawiki.org/wiki/Extension:MaintenanceShell">Extension:MaintenanceShell</a> documentation for more details.',
-	'maintshell-installfail3'  => 'To install MaintenanceShell, put the following code on the <b>very last line</b> of LocalSettings.php:</br />require_once( "$IP/extensions/MaintenanceShell/MaintenanceShell.php" );',
-	'maintshell-return'        => 'Return to the Maintenance Shell',
-	'maintshell-noexist'       => "Script '$1.php' does not exist!",
-	'maintshell-warning'       => 'Warning: Use these scripts with care.  They are intended for administrators and other advanced users only.',
-	'maintshell-links'         => '<ul style="padding-top: 1em;">
-	<li><a class="external" href="https://www.mediawiki.org/wiki/Manual:Maintenance_scripts">Manual:Maintenance scripts</a></li>
-	<li><a class="external" href="https://www.mediawiki.org/wiki/Extension:MaintenanceShell">MaintenanceShell Homepage</a></li>
-</ul>',
-	'maintshell-available'     => 'Available maintenance scripts:',
-	'maintshell-scriptname'    => 'Script name',
-	'maintshell-commandline'   => 'Command line options',
-	'maintshell-runscript'     => 'Run script',
 
+	# Special:ListGroupRights
+	'right-maintenanceshell'    => 'Execute maintenance scripts',
+	# FormSpecialPage "You don't have access to {$action}, for the following reason:"
+	'action-maintenanceshell'   => 'the maintenance shell',
+
+	# Special:MaintenanceShell
+	'maintenanceshell-legend'            => 'Maintenance Shell',
+	'maintenanceshell-text'              => "'''Warning:''' Use these scripts with care.  They are intended for developers only.
+* [//www.mediawiki.org/wiki/Manual:Maintenance_scripts Manual:Maintenance scripts]
+* [//www.mediawiki.org/wiki/Extension:MaintenanceShell Extension:MaintenanceShell]
+",
+	'maintenanceshell-return'            => 'Return to [[{{#special:maintenanceshell}}]].',
+	'maintenanceshell-error-scriptname'  => 'Script not found',
+	'maintenanceshell-error-rawsubmit'   => 'For security reasons, this page requires javascript to be enabled.',
+	'maintenanceshell-available'         => 'Available maintenance scripts:',
+	'maintenanceshell-field-scriptname'  => 'Script name:',
+	'maintenanceshell-field-args'        => 'Command line options:',
+	'maintenanceshell-field-submit'      => 'Run script',
 );
 
 
@@ -32,29 +33,22 @@
  * @author kghbln
  */
 $messages['de'] = array(
-	'maintenanceshell'         => 'Wartungs-Shell',
-	'maintenanceshell-desc'    => 'Erg&auml;nzt eine [[Special:MaintenanceShell|Spezialseite]] mit hilfreichen Links zu Wartungsskripten f&uuml;r die Systemadministration.',
-	'maintshell-pagename'      => 'Special:Wartungs-Shell',
-	'right-maintenanceshell'   => 'Wartungsskripte &uuml;ber die Wartungs-Shell ausf&uuml;hren.',
-	'maintshell-installfail'   => 'Das Benutzergruppenrecht <b>maintenanceshell</b>, das f&uuml;r die Verwendung der Wartungs-Shell ben&ouml;tigt wird, wurde keiner Benutzergruppe zugeordnet.<br /><br />Weitere Hinweise hierzu gibt es in der <a href="https://www.mediawiki.org/wiki/Extension:MaintenanceShell">Dokumentation zur Wartungs-Shell</a>.',
-	'maintshell-installfail2'  => '<b>Die Wartungs-Shell wurde fehlerhaft konfiguriert.</b><br />Das Benutzergruppenrecht <b>maintenanceshell</b> muss in der Datei <b>LocalSettings.php</b> einer Benutzergruppe zugewiesen werden, bevor dort die Softwareerweiterung Wartungs-Shell aufgerufen wird.<br /><br />Weitere Hinweise hierzu gibt es in der <a href="https://www.mediawiki.org/wiki/Extension:MaintenanceShell">Dokumentation zur Wartungs-Shell</a>.',
-	'maintshell-installfail3'  => 'Um die Wartungs-Shell zu aktivieren, muss folgender Code in der <b>allerletzten Zeile</b> der Datei <b>LocalSettings.php</b> eingef&uuml;gt werden:</br />require_once( "$IP/extensions/MaintenanceShell/MaintenanceShell.php" );',
-	'maintshell-return'        => 'R&uuml;ckkehr zur Wartungs-Shell',
-	'maintshell-noexist'       => "Das Skript '$1.php' ist nicht vorhanden!",
-	'maintshell-warning'       => '<b><u>Achtung:</u> Setze diese Skripte sorgf&auml;ltig ein. Dies wird zudem nur Systemadministratoren und fortgeschrittenen Nutzern empfohlen.</b>',
-	'maintshell-links'         => '<ul style="padding-top: 1em;">
-	<li><a class="external" href="https://www.mediawiki.org/wiki/Manual:Maintenance_scripts">Nutzeranleitung f&uuml;r die Wartungs-Shell (englisch)</a></li>
-	<li><a class="external" href="https://www.mediawiki.org/wiki/Extension:MaintenanceShell">Hompage zur Wartungs-Shell (englisch) </a></li>
-</ul><br />',
-	'maintshell-available'     => '<br /><b>Verf&uuml;gbare Wartungsskripte:</b>',
-	'maintshell-scriptname'    => 'Name des Skrips',
-	'maintshell-commandline'   => 'Zus&auml;tzliche Kommandos',
-	'maintshell-runscript'     => 'Skript ausf&uuml;hren',
+	'maintenanceshell' => 'Wartungs-Shell',
+	'maintenanceshell-desc' => 'Erg&auml;nzt eine [[Special:MaintenanceShell|Spezialseite]] mit hilfreichen Links zu Wartungsskripten f&uuml;r die Systemadministration.',
+	'maintenanceshell-pagename' => 'Special:Wartungs-Shell',
+	'right-maintenanceshell' => 'Wartungsskripte &uuml;ber die Wartungs-Shell ausf&uuml;hren.',
+	'maintenanceshell-warning' => "'''Achtung:''' Setze diese Skripte sorgf&auml;ltig ein. Dies wird zudem nur Systemadministratoren und fortgeschrittenen Nutzern empfohlen.",
+	'maintenanceshell-return' => 'R&uuml;ckkehr zur Wartungs-Shell',
+	'maintenanceshell-noexist' => 'Das Skript ist nicht vorhanden',
+	'maintenanceshell-available' => 'Verf&uuml;gbare Wartungsskripte:',
+	'maintenanceshell-field-script' => 'Name des Skrips:',
+	'maintenanceshell-commandline' => 'Zus&auml;tzliche Kommandos:',
+	'maintenanceshell-field-args' => 'Skript ausf&uuml;hren',
 );
 
 /** German (formal address) (Deutsch (Sie-Form))
  * @author kghbln
  */
 $messages['de-formal'] = array(
-	'maintshell-warning'       => '<b><u>Achtung:</u> Setzen Sie diese Skripte sorgf&auml;ltig ein. Dies wird zudem nur Systemadministratoren und fortgeschrittenen Nutzern empfohlen.</b>',
+	'maintenanceshell-warning'       => "'''Achtung:''' Setzen Sie diese Skripte sorgf&auml;ltig ein. Dies wird zudem nur Systemadministratoren und fortgeschrittenen Nutzern empfohlen.",
 );
diff --git a/MaintenanceShell.php b/MaintenanceShell.php
old mode 100755
new mode 100644
index 23b8af9..5859797
--- a/MaintenanceShell.php
+++ b/MaintenanceShell.php
@@ -1,220 +1,64 @@
 <?php
-/***********************************************************
- * Name:     Maintenance Shell
- * Desc:     Adds a special page to provide access to maintenance scripts
+/**
+ * Maintenance Shell extension.
+ * Adds a special page to provide access to maintenance scripts
  *
- * Version:  0.3.2
- *
- * Author:   Andrew Fitzgerald ([email protected])
- * Homepage: https://www.mediawiki.org/wiki/Extension:MaintenanceShell
- *           http://www.swiftlytilting.com/
- *
- * License:  GNU GPL
- *
- ***********************************************************
+ * @file
+ * @ingroup Extensions
+ * @copyright 2009-2013 Andrew Fitzgerald <[email protected]>
+ * @license GNU GPL
  */
 
-
-# Alert the user that this is not a valid entry point to MediaWiki if they try to access the special pages file directly.
-if (!defined('MEDIAWIKI')) {
-		echo wfMsg_MS('maintshell-installfail3');
-		exit( 1 );
-}
-
 $wgExtensionCredits['specialpage'][] = array(
 	'name' => 'MaintenanceShell',
-	'author' => '[http://swiftlytilting.com Andrew Fitzgerald]',
+	'author' => array(
+		'[http://swiftlytilting.com Andrew Fitzgerald]',
+		'Timo Tijhof',
+	),
 	'url' => 'https://www.mediawiki.org/wiki/Extension:MaintenanceShell',
 	'description' => 'Adds a special page to provide access to maintenance scripts.',
 	'descriptionmsg' => 'maintenanceshell-desc',
-	'version' => '0.3.2',
+	'version' => '0.4.0',
 );
 
-$dir = dirname(__FILE__) . '/';
 
-$wgAutoloadClasses['MaintenanceShell'] = $dir . 'MaintenanceShell_body.php'; # Tell MediaWiki to load the extension body.
-$wgExtensionMessagesFiles['MaintenanceShell'] = $dir . 'MaintenanceShell.i18n.php';
-$wgExtensionAliasesFiles['MaintenanceShell'] = $dir . 'MaintenanceShell.alias.php';
-$wgSpecialPages['MaintenanceShell'] = 'MaintenanceShell'; # Let MediaWiki know about your new special page.
+/* Setup */
 
-// Special page group for MW 1.13+
+$dir = dirname( __FILE__ );
+
+// Register files
+$wgAutoloadClasses['MaintenanceShellHooks'] = $dir . '/MaintenanceShell.hooks.php';
+$wgAutoloadClasses['SpecialMaintenanceShell'] = $dir . '/includes/SpecialMaintenanceShell.php';
+$wgAutoloadClasses['MaintenanceShellArgumentsParser'] = $dir . '/includes/MaintenanceShellArgumentsParser.php';
+$wgExtensionMessagesFiles['MaintenanceShell'] = $dir . '/MaintenanceShell.i18n.php';
+$wgExtensionMessagesFiles['MaintenanceShellAlias'] = $dir . '/MaintenanceShell.alias.php';
+
+// Register special pages
+$wgSpecialPages['MaintenanceShell'] = 'SpecialMaintenanceShell';
 $wgSpecialPageGroups['MaintenanceShell'] = 'wiki';
 
-// New user right - required to access Special:MaintenanceShell
+// Register user rights
 $wgAvailableRights[] = 'maintenanceshell';
 
-// check that there is a group assigned to maintenanceshell
-$wgMaintShellPermissions = 0;
-foreach ($wgGroupPermissions as $v)
-{ $wgMaintShellPermissions += array_key_exists('maintenanceshell', $v) ? 1 : 0;
-}
+// Register hooks
+$wgExtensionFunctions[] = 'MaintenanceShellHooks::onSetup';
+$wgHooks['UnitTestsList'][] = 'MaintenanceShellHooks::onUnitTestsList';
 
-// load language file
-require_once($IP . '/extensions/MaintenanceShell/MaintenanceShell.i18n.php');
+// Register modules
+$wgResourceModules['ext.maintenanceShell'] = array(
+	'scripts' => 'resources/ext.maintenanceShell.js',
+	'styles' => 'resources/ext.maintenanceShell.css',
+	'localBasePath' => $dir,
+	'remoteExtPath' => 'MaintenanceShell',
+	'dependencies' => 'jquery.spinner'
+);
 
-// check for custom settings
+// Not granted to anyone by default.
+// To grant to "developer" group, use:
+// $wgGroupPermissions['developer']['maintenanceshell'] = true;
+// Or create a new user group, use:
+// $wgGroupPermissions['maintainer']['maintenanceshell'] = true;
 
-$wgMaintenanceShellLang = isset($wgLanguageCode) ? $wgLanguageCode : 'en';
-$maintenance_path = isset( $wgMaintenancePath) ?  $wgMaintenancePath :  $IP . '/maintenance/';
+/* Configuration */
 
-
-// catch operations before wiki does anything, so we can act like we're coming from the command line
-// we use $_POST because $wgRequest isn't initialized, not really needed anyways
-
-if (!array_key_exists('commandline', $_POST)
-	&& !array_key_exists('token', $_POST) // make sure it was really from the user and not a fake
-) {
-	return;	 // bail if we're not coming from the command line form
-}
-else {
-	// define some functions we'll need.  could load then from MW but it's faster to write them
-	// than figure out how to cleanly load them from MW
-	// at least now they don't load unless we're actually using the maintenance shell
-
-	function wfMsg_MS($key) {
-		global $wgMaintenanceShellLang, $messages, $wgRequest;
-		$args = func_get_args();
-		array_shift( $args );
-
-
-		if (array_key_exists($wgMaintenanceShellLang, $messages)
-			&& array_key_exists($key, $messages[$wgMaintenanceShellLang])
-		) {
-			$ret = $messages[$wgMaintenanceShellLang][$key];
-		}
-		elseif (array_key_exists('en', $messages)) {
-			$ret = $messages['en'][$key];
-		} else {
-			$ret = '[ERROR: string not found for this language]';
-		}
-
-		return wfMsgReplaceArgs_MS($ret, $args);
-	}
-
-	function wfMsgReplaceArgs_MS( $message, $args ) {
-		# Fix windows line-endings
-		# Some messages are split with explode("\n", $msg)
-		$message = str_replace( "\r", '', $message );
-
-		// Replace arguments
-		if ( count( $args ) ) {
-				if ( is_array( $args[0] ) ) {
-						$args = array_values( $args[0] );
-				}
-				$replacementKeys = array();
-				foreach( $args as $n => $param ) {
-						$replacementKeys['$' . ($n + 1)] = $param;
-				}
-				$message = strtr( $message, $replacementKeys );
-		}
-
-		return $message;
-	}
-}
-
-
-$maintshell_pagename = wfMsg_MS( 'maintshell-pagename');
-
-if (array_key_exists('title', $_REQUEST)
-	&& ($_REQUEST['title'] == $maintshell_pagename)
-) {
-	// first lets check to see if we're installed correctly
-	if ($wgMaintShellPermissions === 0) {
-		echo wfMsg_MS('maintshell-installfail');
-		exit;
-	}
-
-	// set up system to verify user permissions
-	require_once('./includes/Setup.php');
-
-	if ( $wgUser->isBlocked()
-		|| wfReadOnly()
-		|| !$wgUser->isAllowed( 'maintenanceshell' )
-		|| $_POST['token'] !== $wgUser->getToken()
-	) {
-
-		$head_redirect = 'Location: '
-			. ($_SERVER['SERVER_PORT'] == "443" ? "https" : "http")
-			. "://"
-			. $_SERVER['SERVER_NAME']
-			. ($_SERVER['SERVER_PORT'] == "80" ? "" : $_SERVER['SERVER_PORT'])
-			. $_SERVER['SCRIPT_NAME']
-			. '?title='
-			. $maintshell_pagename;
-
-		header($head_redirect);
-		return;
-	}
-
-	echo '<a href="' . $_SERVER['SCRIPT_NAME'] . '?title=' . $maintshell_pagename . '">'. wfMsg_MS('maintshell-return') .  '</a>';
-
-	echo '<hr />';
-	$script = trim($_POST['script']);
-
-	if ($script) {
-		$script = str_replace(array('.', '/'), '', $script);
-		$scriptHTML  = MaintenanceShell::HTMLescape($script);
-
-		$commandline = (array_key_exists('commandline', $_POST) ? trim($_POST['commandline']) : '');
-		$commandlineHTML = MaintenanceShell::HTMLescape($commandline);
-
-
-		if (file_exists(trim($maintenance_path . $script . '.php'))) {
-			// display shell frame
-			echo '<div style="border: 5px solid gray; background-color: black; color: green; padding: 1em;">';
-			echo '<pre style="white-space: -moz-pre-wrap; white-space: -pre-wrap; white-space: -o-pre-wrap; white-space: pre-wrap; word-wrap: break-word;"><b>';
-			chdir(isset($wgMaintenanceShellDir) ? $wgMaintenanceShellDir : $maintenance_path);
-			echo getcwd() . '$ php '. (isset($wgMaintenanceShellDir) ? $maintenance_path : '') .$script . '.php ' . $commandlineHTML  . "</b>\n\n";
-
-			// make commandline.inc think we're coming from the command line
-			// Unset request method and build $argv
-			unset($_SERVER['REQUEST_METHOD']);
-
-			$commandline = str_ireplace('{{root}}', $_SERVER['DOCUMENT_ROOT'], $commandline);
-
-			// handle quote marks in command line
-
-			preg_match_all('%\"(.*)\"%Us', $commandline, $matches);
-
-			foreach ($matches[1] as $n => $v) {
-				$commandline = str_replace($v, str_replace(' ', "\n", $v), $temp_command );
-			}
-
-			$temp_command = preg_replace('% +%', ' ', $commandline);
-			$argv = explode(' ',basename($_SERVER["PHP_SELF"]) . ' ' . $commandline);
-
-			$search = array( '"', "\n");
-			$replace = array('', ' ');
-			foreach($argv as $n => $v) {
-				$argv[$n] = str_replace($search, $replace, $v);
-			}
-
-			$argc = count($argv);
-
-			// catch exit calls from within the called script
-			function exit_callback($param = false) {
-				echo '</pre></div>';
-				exit;
-			}
-
-			register_shutdown_function('exit_callback');
-
-			// needed for MW 1.15
-			// apparently sometimes 2 ob_starts are needed?!  not sure why..
-			// it's best when the output isn't buffered at all, then in firefox it displays as the form
-			// exports it
-			ob_start(); ob_start();
-
-			// call the script
-			include_once($maintenance_path . $script . '.php');
-
-			// exit, if the script doesn't explictly exit.  Will call the exit callback function
-			exit;
-
-
-		} else {
-			echo wfMsg_MS('maintshell-noexist',  $maintenance_path .$scriptHTML);
-		}
-	}
-	exit;
-}
+$wgMaintenanceShellPath = false;
diff --git a/MaintenanceShell_body.php b/MaintenanceShell_body.php
deleted file mode 100755
index e7d631d..0000000
--- a/MaintenanceShell_body.php
+++ /dev/null
@@ -1,90 +0,0 @@
-<?php
-
-
-/**
- * Special page interface for Maintenance Shell
- */
-class MaintenanceShell extends SpecialPage {
-	function __construct() {
-		parent::__construct( 'MaintenanceShell', 'maintenanceshell' );
-		wfLoadExtensionMessages('MaintenanceShell');
-	}
-
-	function execute( $par ) {
-		global $wgRequest, $wgOut, $IP, $wgUser, $wgMaintShellPermissions;
-
-		# If user is blocked, s/he doesn't need to access this page
-		if ( $wgUser->isBlocked() ) {
-			$wgOut->blockedPage();
-			return;
-		}
-
-		# Show a message if the database is in read-only mode
-		if ( wfReadOnly() ) {
-			$wgOut->readOnlyPage();
-			return;
-		}
-
-		# If the user doesn't have the required 'maintenanceshell' permission, display an error
-		if( !$wgUser->isAllowed( 'maintenanceshell' )) {
-			$wgOut->permissionRequired( 'maintenanceshell' );
-			return;
-		}
-
-		$this->setHeaders();
-
-		$token = $wgUser->getToken();
-
-
-		if ($wgMaintShellPermissions === 0) {
-			$wgOut->addHTML(wfMsg('maintshell-installfail2'));
-			return;
-		}
-
-		$scriptNameHTML = self::HTMLescape($wgRequest->getText('script'));
-		$commandlineHTML = self::HTMLescape($wgRequest->getText('commandline'));
-
-		$maintenance_path = $IP.'/maintenance/';
-		$str = '<b>' . wfMsg('maintshell-warning'). '</b>' .
-			"<br /><br /><form action='$_SERVER[SCRIPT_NAME]' method='post'>" .
-			'<input name="title" value="Special:MaintenanceShell" type="hidden">' .
-			"<table><tr><td><b>". wfMsg('maintshell-scriptname') ."</b>:</td><td> <input name='script' value='" . $scriptNameHTML .  "'/>.php</td></tr>" .
-			'<tr><td><b>'. wfMsg('maintshell-commandline') .'</b>:</td><td> <input name="commandline" size="70" value="' .  $commandlineHTML .'"/></td></tr>' .
-			'</table><br /><input name="submit" type="submit" value="' . wfMsg('maintshell-runscript') . '"/>'
-			.'<input type="hidden" name="token" value="' .$token . '" /> </form>'. wfMsg('maintshell-links') .'<hr />';
-
-
-		$wgOut->addHTML($str);
-
-		$files = array_diff( scandir($maintenance_path ), array( ".", ".." ) );
-		if (count($files)) {
-			$wgOut->addHTML('<b>'. wfMsg('maintshell-available') .'</b><pre><table cellpadding=5><tr>');
-			$count = 0;
-			foreach ($files as $v) {
-				if (($arr = pathinfo($v)) && array_key_exists('extension', $arr) && ($arr['extension'] == 'php') && (strpos($v, '.inc') === false)) {
-					$v = str_replace('.php','',$v);
-					$wgOut->addHTML( "<td><a href='$_SERVER[SCRIPT_NAME]?title=". wfMsg('maintshell-pagename') ."&script=$v'>$v</a></td>");
-					$count++;
-					if ($count == 4) {
-						$wgOut->addHTML( '</tr><tr>');
-						$count = 0;
-					}
-				}
-			}
-			$wgOut->addHTML('</tr></table>');
-
-		}
-
-		$wgOut->addHTML('</pre>');
-
-	}
-
-	/**
-	 * aggressively escape everything besides letters and numbers
-	 */
-	static function HTMLescape($string) {
-			return preg_replace_callback('%([^A-Za-z0-9 ])%',
-				 create_function('$matches', 'return "&#" . ord($matches[1]) .";" ;'),
-				 $string);
-	}
-}
diff --git a/includes/AlteredSelectedMaintenanceScript.php b/includes/AlteredSelectedMaintenanceScript.php
new file mode 100644
index 0000000..844857a
--- /dev/null
+++ b/includes/AlteredSelectedMaintenanceScript.php
@@ -0,0 +1,40 @@
+<?php
+
+class AlteredSelectedMaintenanceScript extends SelectedMaintenanceScript {
+
+	/**
+	 * Copied from core Maintenance::setup.
+	 *
+	 * Overriding it to exclude these checks:
+	 *
+	 * - `ini_get( 'register_argc_argv' )`
+	 *   > Disabled by default in many PHP configurations for Apache servers.
+	 *
+	 * - `$_SERVER['REQUEST_METHOD']`
+	 *   > Because we're not actually on the command line. This can be simulated
+	 *     using unset(), but since we're overriding this anyway, might as well remove it.
+	 *
+	 * - `define( 'MEDIAWIKI', true )`
+	 *   > This would throw an E_NOTICE, since we're already in MediaWiki request context.
+	 */
+	public function setup() {
+		global $wgCommandLineMode, $wgRequestTime;
+
+		if ( ini_get( 'display_errors' ) ) {
+			ini_set( 'display_errors', 'stderr' );
+		}
+
+		$this->loadParamsAndArgs();
+		$this->maybeHelp();
+		$this->adjustMemoryLimit();
+
+		ini_set( 'max_execution_time', 0 );
+
+		$wgRequestTime = microtime( true );
+		$wgCommandLineMode = true;
+
+		@ob_end_flush();
+
+		$this->validateParamsAndArgs();
+	}
+}
diff --git a/includes/MaintenanceShellArgumentsParser.php b/includes/MaintenanceShellArgumentsParser.php
new file mode 100644
index 0000000..a4e510f
--- /dev/null
+++ b/includes/MaintenanceShellArgumentsParser.php
@@ -0,0 +1,156 @@
+<?php
+
+class MaintenanceShellArgumentsParser {
+	private $raw;
+	private $arguments;
+
+	public function __construct( $str ) {
+		$this->raw = strval( $str );
+	}
+
+	/**
+	 * Get the supposed-be value of $argv given a string that
+	 * would be written literally on the command line.
+	 * This basically simulates the bash intepreter
+	 * and (where relevant) PHPs specific perception of it
+	 * as exposed in $arv. See also <http://php.net/argv/>.
+	 *
+	 * @return Array
+	 */
+	public function getArgv() {
+		return $this->parseArguments();
+	}
+
+	public function getArgc() {
+		return count( $this->parseArguments() );
+	}
+
+	private function parseArguments() {
+		if ( $this->arguments !== null ) {
+			return $this->arguments;
+		}
+
+		// Trim leading whitespace (not after backslash escape or in quotes)
+		$chars = ltrim( $this->raw );
+
+		// Stable results
+		$values = array();
+
+		// Continuous state
+		$value = false;
+		$inSingleWrap = false;
+		$inDoubleWrap = false;
+		$escapeNext = false;
+
+		// Helper variables
+		$inEscape = false;
+		$inWrap = false;
+
+		$chars_len = strlen( $chars );
+		for ( $i = 0; $i < $chars_len; $i++ ) {
+			$char = $chars[$i];
+
+			$inEscape = $escapeNext;
+			$escapeNext = false;
+			$inWrap = $inSingleWrap || $inDoubleWrap;
+
+			if ( $char === '\\' ) {
+				// Plain or wrapped
+				if ( !$inWrap ) {
+					if ( !$inEscape ) {
+						$escapeNext = true;
+						// Beginning of a plain value of which the first character
+						// (next iteration) will be escaped.
+						if ( $value === false ) {
+							$value = '';
+						}
+					} else {
+						if ( $value === false ) {
+							// XXX: Is it even possible to be in escape with value being false?
+							$value = $char;
+						} else {
+							$value .= $char;
+						}
+					}
+				// Wrapped
+				} else {
+					if ( $value === false ) {
+						$value = $char;
+					} else {
+						$value .= $char;
+					}
+				}
+
+			} elseif ( $char === '"' ) {
+				// Plain: Begin wrap
+				if ( !$inWrap ) {
+					$inDoubleWrap = true;
+
+				// Double wrap: End of wrap
+				} elseif ( $inDoubleWrap ) {
+					$inDoubleWrap = false;
+
+				// Single wrap: Literal value
+				} elseif ( $inSingleWrap ) {
+					$value .= $char;
+				}
+			} elseif ( $char === "'" ) {
+				// Plain: Begin wrap
+				if ( !$inWrap ) {
+					$inSingleWrap = true;
+
+				// Single wrap: End of wrap
+				} elseif ( $inSingleWrap ) {
+					$inSingleWrap = false;
+
+				// Double wrap: Literal value
+				} elseif ( $inDoubleWrap ) {
+					$value .= $char;
+				}
+			} elseif ( $char === ' ' ) {
+				// Plain
+				if ( !$inWrap ) {
+					if ( !$inEscape ) {
+						if ( $value !== false ) {
+							array_push( $values, $value );
+							$value = false;
+						}
+						// else: do nothing.
+						// We're in plain context with no escape and no value yet.
+						// Additional separator spaces are just ignored.
+					} else {
+						if ( $value === false ) {
+							$value = $char;
+						} else {
+							$value .= $char;
+						}
+					}
+				// Wrapped
+				} else {
+					// XXX: Should escaping be considered here?
+					if ( $value === false ) {
+						$value = $char;
+					} else {
+						$value .= $char;
+					}
+				}
+			} else {
+				if ( $value === false ) {
+					$value = $char;
+				} else {
+					$value .= $char;
+				}
+			}
+		}
+
+		// If there was no trailing space, make sure we push in the
+		// last value as well.
+		if ( $value !== false ) {
+			array_push( $values, $value );
+		}
+
+		$this->arguments = $values;
+		return $this->arguments;
+	}
+
+}
diff --git a/includes/SpecialMaintenanceShell.php b/includes/SpecialMaintenanceShell.php
new file mode 100644
index 0000000..9f16957
--- /dev/null
+++ b/includes/SpecialMaintenanceShell.php
@@ -0,0 +1,170 @@
+<?php
+
+/**
+ * Special page interface for Maintenance Shell.
+ *
+ * TODO: FormSpecialPage implements a token protection.
+ * That's good but ideally we'd use a custom token (Like FormAction allows).
+ */
+class SpecialMaintenanceShell extends FormSpecialPage {
+	private $maintshellOutput = '';
+
+	public function __construct() {
+		parent::__construct( 'MaintenanceShell', 'maintenanceshell' );
+		$out = $this->getOutput();
+		$out->addModules( 'ext.maintenanceShell' );
+	}
+
+	public function execute( $par ) {
+		$out = $this->getOutput();
+		$out->addHTML( '<div class="mw-sp-maintenanceShell">' );
+		parent::execute( $par );
+		$out->addHTML( '</div>' );
+	}
+
+	/**
+	 * @return Array
+	 */
+	protected function getFormFields() {
+		global $wgMaintenanceShellPath;
+
+		$files = array_map( 'basename', glob( $wgMaintenanceShellPath . '/*.php' ) );
+		$options = array_combine( $files, $files );
+
+		// Blacklist
+		unset( $options['Maintenance.php'] );
+		unset( $options['doMaintenance.php'] );
+
+		// Add empty value to top of the list
+		// Note: HTMLForm automatically considers '' to be an invalid
+		// value if required:true is set.
+		$options[''] = '';
+
+		ksort( $options );
+
+		return array(
+			'Script' => array(
+				'type' => 'select',
+				'label-message' => 'maintenanceshell-field-scriptname',
+				'tabindex' => '1',
+				'size' => '45',
+				'required' => true,
+				'options' => $options,
+				'default' => '',
+			),
+			'Arguments' => array(
+				'type' => 'text',
+				'label-message' => 'maintenanceshell-field-args',
+				'tabindex' => '1',
+				'size' => '70',
+			)
+		);
+	}
+
+	protected function alterForm( HTMLForm $form ) {
+		$form->setSubmitTextMsg( 'maintenanceshell-field-submit' );
+		/* Control field to prevent accidential submissions
+		 * in browsers without javascript support.
+		 *
+		 * We require the javascript module to have successfully
+		 * done its work, as otherwise there is a potential of the
+		 * form being submitted directly which in IE can cause
+		 * the output to be interpretted as html.
+		 */
+		$form->addHiddenField( 'controlfield', '0', array( 'class' => 'mw-sp-maintenanceShell-controlfield' ) );
+	}
+
+	/**
+	 * After form input is validated, we run the maintenance script.
+	 * This is before the page output is generated.
+	 *
+	 * @param Array $data
+	 * @return Bool|Array true on success, array of errors on failure
+	 */
+	public function onSubmit( Array $data ) {
+		global $wgMaintenanceShellPath;
+
+		$filePath = $wgMaintenanceShellPath . '/' . $data['Script'];
+		// Verify one more time, in case of race condition or forged submission.
+		if ( !is_file( $filePath ) ) {
+			return array( 'maintenanceshell-error-scriptname' );
+		}
+
+		if ( $this->getRequest()->getVal('controlfield') !== '1' ) {
+			return array( 'maintenanceshell-error-rawsubmit' );
+		}
+
+		$this->mainshellExec( $filePath, $data['Arguments'] );
+
+		#$this->maintshellPrompt = getcwd() . '$ php ' . $data['Script'] . ' ' . $data['Arguments'];
+		#$this->maintshellOutput = $this->mainshellExec( $filePath, $data['Arguments'] );
+		#return true;
+	}
+
+
+	public function onSuccess() {
+		// Never happens because onSubmit callback to mainshellExec
+		// will exit before. We handle this in the front-end instead
+		// because maintenance scripts are hard to catch the output of.
+		// We instead let the script output and exit, but we'lll make the
+		// request itself go through AJAX, so the javascript module can
+		// safely catch the output and present it to the user.
+	}
+
+	/**
+	 * Execute the script and echo output
+	 * to the browser as plain text.
+	 */
+	private function mainshellExec( $filePath, $arguments ) {
+		global $wgMaintenanceShellPath, $IP, $wgTitle;
+
+		// Replace placeholders
+		$arguments = str_ireplace( '{{root}}', $_SERVER['DOCUMENT_ROOT'], $arguments );
+
+		// Simulate working directory
+		chdir( $wgMaintenanceShellPath );
+
+		// Build $argv and $argc
+		$arguments = basename( $filePath ) . ' ' . $arguments;
+		$parser = new MaintenanceShellArgumentsParser( $arguments );
+		$GLOBALS['argv'] = $parser->getArgv();
+		$GLOBALS['argc'] = $parser->getArgc();
+
+		// Output plain text header to avoid output being misintepreted as html
+		header( 'Content-Type: text/plain; charset=utf-8' );
+
+		require_once( $filePath );
+
+		// We could eval the entire extension, but lets only eval the part
+		// we need, namely the variable class extension.
+		eval( "class SelectedMaintenanceScript extends $maintClass {}" );
+
+		// We need some alterations to the maintenance class to make it
+		// work in a web request context.
+		require_once( __DIR__ . '/AlteredSelectedMaintenanceScript.php' );
+
+		// "DoMaintenance" (loaded from the maintenance script file) will have refused
+		// to run the script, as Maintenance::shouldExecute returns false because
+		// we're not on the command line and not in the global context.
+		// So, reverse-engineer its logic here, using our altered version of the class.
+		if ( !Maintenance::shouldExecute() ) {
+			$maintenance = new AlteredSelectedMaintenanceScript();
+			$maintenance->setup();
+			// Already handled by our current request context:
+			# require( $maintenance->loadSettings() );
+			# AdminSettings.php
+			# Setup.php
+			$maintenance->finalSetup();
+			$wgTitle = null;
+			try {
+				$maintenance->execute();
+				$maintenance->globals();
+			} catch ( MWException $mwe ) {
+				echo $mwe->getText();
+			}
+		}
+
+		// If the script doesn't explictly exit, we'll exit anyway
+		exit;
+	}
+}
diff --git a/resources/ext.maintenanceShell.css b/resources/ext.maintenanceShell.css
new file mode 100644
index 0000000..287c195
--- /dev/null
+++ b/resources/ext.maintenanceShell.css
@@ -0,0 +1,13 @@
+.mw-sp-maintenanceShell-shell {
+	padding: 1em;
+	background: #000;
+	color: #f9f9f9;
+	font: normal 12px/1.4 Menlo, Monaco, 'Courier', monospace;
+	white-space: pre-wrap;
+	word-wrap: break-word;
+}
+
+.mw-sp-maintenanceShell-shell-error b {
+	color: red;
+	font-weight: bold;
+}
diff --git a/resources/ext.maintenanceShell.js b/resources/ext.maintenanceShell.js
new file mode 100644
index 0000000..777c1a0
--- /dev/null
+++ b/resources/ext.maintenanceShell.js
@@ -0,0 +1,91 @@
+( function ( $ ) {
+
+	function err( $shell, errorMsg ) {
+		$shell.append(
+			$('<p><b>&gt;</b> </p>' )
+				.addClass( 'mw-sp-maintenanceShell-shell-error' )
+				.append( document.createTextNode( errorMsg ) )
+		);
+	}
+
+	function init( $pageWrap ) {
+		$pageWrap
+		.find( '.mw-sp-maintenanceShell-controlfield' )
+			.val( '1' )
+			.end()
+		.find( 'select[name="wpScript"]' )
+			.prop( 'required', true )
+			.end()
+		.find( 'form' )
+			.on( 'submit', function ( e ) {
+				// We're going the ajax way!
+				e.preventDefault();
+
+				// Get data
+				var $spinner,
+					$tmpWrap,
+					$form = $( this ),
+					postData = $form.serialize(),
+					$inputs = $form.find( ':input' ), // select, input, textarea, button 
+					$wrap = $form.closest( '.mw-sp-maintenanceShell' ),
+					$shell = $wrap.find( '.mw-sp-maintenanceShell-shell' ).empty();
+				if ( !$shell.length ) {
+					$shell = $( '<div>' ).addClass( 'mw-sp-maintenanceShell-shell' ).appendTo( $wrap );
+				}
+				// Remove stale errors from $tmpWrap
+				$wrap.find( '.error' ).remove();
+
+				// Disable form and show spinner
+				$inputs.prop( 'disabled', true ); // Could store original state, but we don't have disabled fields..
+				$spinner = $.createSpinner({ size: 'large', type: 'block' });
+				$form.find( 'fieldset' ).eq( 0 ).append( $spinner );
+
+				// So far the only known restriction/limitation: --wiki doens't work
+				// (which could be, but is purposely not supported. If you run a
+				// wiki farm, you're either expected to have command line access or
+				// just access this SpecialPage from the correct wiki)
+				if ( postData.indexOf( '--wiki' ) !== -1 ) {
+					err( $shell, 'Usage of the --wiki option is not allowed by MaintenanceShell.' );
+					$inputs.prop( 'disabled', false );
+					$spinner.remove();
+					return;
+				}
+
+				// Submission
+				$.ajax( {
+						type: $form.attr( 'method' ) || 'POST',
+						url:  $form.attr( 'action' ) || '',
+						data: postData,
+						cache: false,
+						dataType: 'text'
+					} )
+					.done( function ( data ) {
+						// In case of error, the server will respond with a full html page.
+						// Extract the SpecialPage wrapper, and replace our current one to show the error.
+						// Then re-run our init handlers on the wrapper.
+						if ( data.indexOf( 'mw-sp-maintenanceShell' ) !== -1 || data.indexOf( 'controlfield' ) !== -1 ) {
+							$tmpWrap = $(data).find( '.mw-sp-maintenanceShell' ).eq( 0 );
+							$wrap.replaceWith( $tmpWrap );
+							init( $tmpWrap );
+						} else {
+							$shell.text( data );
+						}
+					} )
+					.fail( function ( jqXHR, textStatus, errorThrown ) {
+						err( $shell, errorThrown || 'Request failed.' );
+					} )
+					.complete( function () {
+						// Re-enable form and hide spinner
+						$inputs.prop( 'disabled', false );
+						$spinner.remove();
+					} );
+
+			} );
+	}
+
+	// Kick it off
+	$( function () {
+		init( $( '.mw-sp-maintenanceShell' ) );
+	} );
+
+}( jQuery ) );
diff --git a/tests/MaintenanceShellArgumentsParserTest.php b/tests/MaintenanceShellArgumentsParserTest.php
new file mode 100644
index 0000000..faf6722
--- /dev/null
+++ b/tests/MaintenanceShellArgumentsParserTest.php
@@ -0,0 +1,117 @@
+<?php
+
+class MaintenanceShellArgumentsParserTest extends MediaWikiTestCase {
+
+	public static function provideArguments() {
+		return array(
+			array(
+				'foo',
+				array( 'foo' ),
+				'One plain argument'
+			),
+			array(
+				'foo bar',
+				array( 'foo', 'bar' ),
+				'Two plain arguments'
+			),
+			array(
+				'foo bar baz',
+				array( 'foo', 'bar', 'baz' ),
+				'Three plain arguments'
+			),
+			array(
+				'foo\ bar baz\ quux',
+				array( 'foo bar', 'baz quux' ),
+				'Plain argument with escaping'
+			),
+			array(
+				'\ foo \ bar \ baz',
+				array( ' foo', ' bar', ' baz' ),
+				'Plain argument with escaping at start of value'
+			),
+			array(
+				' \ foo    \ bar     \ baz ',
+				array( ' foo', ' bar', ' baz' ),
+				'Plain arguments with escaping and extra spacing'
+			),
+			array(
+				"foo\nbar",
+				array( "foo\nbar" ),
+				'Plain argument with line break'
+			),
+			array(
+				'"foo" "bar" "baz"',
+				array( 'foo', 'bar', 'baz' ),
+				'Double quoted arguments'
+			),
+			array(
+				'" foo "  " bar "  " baz "',
+				array( ' foo ', ' bar ', ' baz ' ),
+				'Double quoted arguments with spacing'
+			),
+			array(
+				'" foo\ "  " bar\ "  " baz\ "',
+				array( ' foo\ ', ' bar\ ', ' baz\ ' ),
+				'Double quoted arguments with spacing and backslashes'
+			),
+			array(
+				// one more backslash for each one because of PHP's escaping.
+				// Actual input: "foo\\" "\bar\"
+				'"foo\\\\" "\bar\"',
+				array( 'foo\\\\', '\bar\\' ),
+				'Double quoted arguments with multple backslashes'
+			),
+			array(
+				"\"foo\nbar\"",
+				array( "foo\nbar" ),
+				'Double quoted argument with line break'
+			),
+			array(
+				"'foo' 'bar' 'baz'",
+				array( 'foo', 'bar', 'baz' ),
+				'Single quoted arguments'
+			),
+			array(
+				"' foo '  ' bar '  ' baz '",
+				array( ' foo ', ' bar ', ' baz ' ),
+				'Single quoted arguments with spaces'
+			),
+			array(
+				"'foo\nbar'",
+				array( "foo\nbar" ),
+				'Single quoted argument with line break'
+			),
+			array(
+				'"foo""bar"\'quux\'',
+				array( 'foobarquux' ),
+				'Quotes only provide wrapping context, they are not value seperators'
+			),
+			array(
+				' path/to/something\ in/this\ directory.png  --baz="quux"  -abc="c++"  -d  e --  -f "' . "line\nbreak". '" ',
+				array(
+					'path/to/something in/this directory.png',
+					'--baz=quux',
+					'-abc=c++',
+					'-d',
+					'e',
+					'--',
+					'-f',
+					"line\nbreak",
+				),
+				'Everything thrown together in one giant example (using double quotes)'
+			),
+		);
+	}
+
+	/**
+	 * @dataProvider provideArguments
+	 */
+	public function testParseArguments( $input, Array $expected, $message ) {
+		$parser = new MaintenanceShellArgumentsParser( $input );
+		$this->assertEquals(
+				$expected,
+				$parser->getArgv(),
+				$message
+		);
+	}
+}